Fix CI: Update bun.lock and fix linting errors

This commit is contained in:
Víctor Falcón 2025-12-01 15:15:16 +01:00
parent c339105875
commit 2fefb64710
44 changed files with 1290 additions and 682 deletions

View File

@ -42,6 +42,7 @@
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.5.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
@ -337,6 +338,8 @@
"@react-types/shared": ["@react-types/shared@3.31.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.38", "", {}, "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.46.2", "", { "os": "android", "cpu": "arm" }, "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA=="],
@ -379,6 +382,10 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.9.5", "", { "os": "win32", "cpu": "x64" }, "sha512-1q+mykKE3Vot1kaFJIDoUFv5TuW+QQVaf2FmTT9krg86pQrGStOSJJ0Zil7CFagyxDuouTepzt5Y5TVzyajOdQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.12" } }, "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ=="],
@ -427,6 +434,24 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-logic-js": ["@types/json-logic-js@2.0.8", "", {}, "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q=="],
@ -443,6 +468,8 @@
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg=="],
@ -557,6 +584,28 @@
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
@ -567,6 +616,8 @@
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@ -609,6 +660,8 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"es-toolkit": ["es-toolkit@1.42.0", "", {}, "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA=="],
"esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": "bin/esbuild" }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@ -637,6 +690,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@ -719,6 +774,8 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@ -727,6 +784,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@ -941,6 +1000,8 @@
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
@ -949,12 +1010,20 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"recharts": ["recharts@3.5.1", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@ -1035,6 +1104,8 @@
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@ -1079,6 +1150,8 @@
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@7.1.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ=="],
"vite-plugin-full-reload": ["vite-plugin-full-reload@1.2.0", "", { "dependencies": { "picocolors": "^1.0.0", "picomatch": "^2.3.1" } }, "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA=="],
@ -1145,6 +1218,8 @@
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@reduxjs/toolkit/immer": ["immer@11.0.1", "", {}, "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],

View File

@ -13,7 +13,7 @@
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: "Google Sans Code", monospace;
--font-mono: 'Google Sans Code', monospace;
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
@ -180,7 +180,6 @@
}
}
.font-heading {
font-family: 'Stack Sans Text', ui-sans-serif, system-ui, sans-serif;
}

View File

@ -2,6 +2,13 @@ import '../css/app.css';
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
@ -9,7 +16,6 @@ import { EncryptionKeyProvider } from './contexts/encryption-key-context';
import { SyncProvider } from './contexts/sync-context';
import { initializeTheme } from './hooks/use-appearance';
import type { SharedData } from './types';
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from 'lucide-react';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@ -35,13 +41,20 @@ createInertiaApp({
>
<App {...props} />
<div className="[&_[data-sonner-toaster]]:!top-4 [&_[data-sonner-toaster]]:!left-1/2 [&_[data-sonner-toaster]]:!-translate-x-1/2 [&_[data-sonner-toaster]]:md:!top-auto [&_[data-sonner-toaster]]:md:!right-4 [&_[data-sonner-toaster]]:md:!bottom-4 [&_[data-sonner-toaster]]:md:!left-auto [&_[data-sonner-toaster]]:md:!translate-x-0">
<Toaster richColors
<Toaster
richColors
icons={{
success: <CircleCheckIcon className="size-4" />,
success: (
<CircleCheckIcon className="size-4" />
),
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
warning: (
<TriangleAlertIcon className="size-4" />
),
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
/>
</div>

View File

@ -1,12 +1,18 @@
import { cn } from '@/lib/utils';
import { BirdIcon, SVGAttributes } from 'lucide-react';
export default function AppLogoIcon({ className }: { className?: SVGAttributes['className'] }) {
export default function AppLogoIcon({
className,
}: {
className?: SVGAttributes['className'];
}) {
return (
<BirdIcon className={cn(
"size-5 text-[#1b1b18] dark:text-[#EDEDEC]",
className,
'fill-transparent'
)} />
<BirdIcon
className={cn(
'size-5 text-[#1b1b18] dark:text-[#EDEDEC]',
className,
'fill-transparent',
)}
/>
);
}

View File

@ -146,48 +146,45 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
<div className="space-y-2">
{group.conditions.map((condition) => (
<div key={condition.id}>
<ConditionRow
condition={condition}
onChange={(
updatedCondition,
) => {
updateGroup(group.id, {
...group,
conditions:
group.conditions.map(
(c) =>
c.id ===
condition.id
? updatedCondition
: c,
),
});
}}
onRemove={() => {
if (
group.conditions
.length === 1
) {
return;
}
updateGroup(group.id, {
...group,
conditions:
group.conditions.filter(
(c) =>
c.id !==
condition.id,
),
});
}}
canRemove={
group.conditions.length > 1
<div key={condition.id}>
<ConditionRow
condition={condition}
onChange={(updatedCondition) => {
updateGroup(group.id, {
...group,
conditions:
group.conditions.map(
(c) =>
c.id ===
condition.id
? updatedCondition
: c,
),
});
}}
onRemove={() => {
if (
group.conditions.length ===
1
) {
return;
}
/>
</div>
),
)}
updateGroup(group.id, {
...group,
conditions:
group.conditions.filter(
(c) =>
c.id !==
condition.id,
),
});
}}
canRemove={
group.conditions.length > 1
}
/>
</div>
))}
</div>
<Button

View File

@ -48,7 +48,7 @@ export function AccountBalanceCard({
iv={account.name_iv}
/>
</CardTitle>
<div className="text-muted-foreground text-xs font-medium">
<div className="text-xs font-medium text-muted-foreground">
{account.type}
</div>
</CardHeader>
@ -58,7 +58,7 @@ export function AccountBalanceCard({
<div className="text-2xl font-bold">
{formatter.format(account.currentBalance / 100)}
</div>
<p className="text-muted-foreground flex items-center text-xs">
<p className="flex items-center text-xs text-muted-foreground">
<span
className={
isPositive

View File

@ -25,12 +25,12 @@ export function CashFlowCard({
});
const currentNet = income - expense;
const calculateTrend = () => {
if (!previous) return undefined;
const previousNet = previous.income - previous.expense;
if (previousNet === 0) return undefined;
const diff = currentNet - previousNet;
const percentage = (diff / Math.abs(previousNet)) * 100;

View File

@ -65,7 +65,10 @@ export function NetWorthChart({ data, loading }: NetWorthChartProps) {
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
dataKey="date"
axisLine={false}
@ -100,5 +103,3 @@ export function NetWorthChart({ data, loading }: NetWorthChartProps) {
</Card>
);
}

View File

@ -23,7 +23,7 @@ export function SpendingSummaryCard({
if (!previous) return undefined;
const diff = current - previous;
const percentage = (diff / previous) * 100;
return {
value: percentage,
label: 'from previous period',

View File

@ -31,7 +31,7 @@ export function StatCard({
<CardTitle className="text-sm font-medium">
{title}
</CardTitle>
{Icon && <Icon className="text-muted-foreground size-4" />}
{Icon && <Icon className="size-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div className="h-8 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
@ -45,12 +45,12 @@ export function StatCard({
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{Icon && <Icon className="text-muted-foreground size-4" />}
{Icon && <Icon className="size-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{(description || trend) && (
<div className="text-muted-foreground flex items-center text-xs">
<div className="flex items-center text-xs text-muted-foreground">
{trend && (
<span
className={cn(
@ -77,5 +77,3 @@ export function StatCard({
</Card>
);
}

View File

@ -8,7 +8,10 @@ interface TopCategoriesCardProps {
loading?: boolean;
}
export function TopCategoriesCard({ categories, loading }: TopCategoriesCardProps) {
export function TopCategoriesCard({
categories,
loading,
}: TopCategoriesCardProps) {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
@ -37,7 +40,9 @@ export function TopCategoriesCard({ categories, loading }: TopCategoriesCardProp
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{categories.map((item) => {
const Icon = (Icons[item.category.icon as keyof typeof Icons] || Icons.HelpCircle) as LucideIcon;
const Icon = (Icons[
item.category.icon as keyof typeof Icons
] || Icons.HelpCircle) as LucideIcon;
return (
<div
key={item.category.id}
@ -53,10 +58,10 @@ export function TopCategoriesCard({ categories, loading }: TopCategoriesCardProp
<Icon className="size-5" />
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">
<p className="text-sm leading-none font-medium">
{item.category.name}
</p>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
{formatter.format(item.amount / 100)}
</p>
</div>
@ -64,7 +69,7 @@ export function TopCategoriesCard({ categories, loading }: TopCategoriesCardProp
);
})}
{categories.length === 0 && (
<div className="text-muted-foreground col-span-full py-8 text-center">
<div className="col-span-full py-8 text-center text-muted-foreground">
No spending data this month
</div>
)}
@ -73,5 +78,3 @@ export function TopCategoriesCard({ categories, loading }: TopCategoriesCardProp
</Card>
);
}

View File

@ -72,7 +72,9 @@ export function EncryptedText(props: EncryptedTextProps) {
iv: string;
value: string;
} | null>(null);
const [displayState, setDisplayState] = useState<DisplayState>(() => getInitialDisplayState(isKeySet));
const [displayState, setDisplayState] = useState<DisplayState>(() =>
getInitialDisplayState(isKeySet),
);
const prevIsKeySetRef = useRef(isKeySet);
useEffect(() => {

View File

@ -13,8 +13,13 @@ import {
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { Check, ChevronsUpDown, HelpCircle, type LucideIcon } from 'lucide-react';
import * as Icons from 'lucide-react';
import {
Check,
ChevronsUpDown,
HelpCircle,
type LucideIcon,
} from 'lucide-react';
import { memo, useState } from 'react';
const iconCache = new Map<string, LucideIcon>();
@ -24,7 +29,9 @@ function getIconComponent(iconName?: string): LucideIcon | null {
if (iconCache.has(iconName)) {
return iconCache.get(iconName)!;
}
const icon = Icons[iconName as keyof typeof Icons] as LucideIcon | undefined;
const icon = Icons[iconName as keyof typeof Icons] as
| LucideIcon
| undefined;
if (icon) {
iconCache.set(iconName, icon);
}
@ -69,7 +76,7 @@ export function CategoryCombobox({
role="combobox"
aria-expanded={open}
className={cn(
'max-w-full w-full justify-between !pl-2',
'w-full max-w-full justify-between !pl-2',
triggerClassName,
)}
disabled={disabled}
@ -77,7 +84,9 @@ export function CategoryCombobox({
{selectedCategory ? (
<div className="flex items-center gap-2 overflow-x-hidden">
<CategoryIcon category={selectedCategory} />
<span className='truncate'>{selectedCategory.name}</span>
<span className="truncate">
{selectedCategory.name}
</span>
</div>
) : value === 'null' ? (
<div className="flex items-center gap-2">
@ -134,7 +143,9 @@ export function CategoryCombobox({
>
<div className="flex items-center gap-2">
<CategoryIcon category={category} />
<span className='truncate'>{category.name}</span>
<span className="truncate">
{category.name}
</span>
</div>
<Check
className={cn(
@ -153,7 +164,13 @@ export function CategoryCombobox({
);
}
export const CategoryIcon = memo(function CategoryIcon({ category, size = 5 }: { category: Category, size?: number }) {
export const CategoryIcon = memo(function CategoryIcon({
category,
size = 5,
}: {
category: Category;
size?: number;
}) {
const colorClasses = getCategoryColorClasses(category.color);
const iconName = category.icon;
@ -165,14 +182,22 @@ export const CategoryIcon = memo(function CategoryIcon({ category, size = 5 }: {
colorClasses.bg,
)}
>
<DynamicIcon name={iconName} className={cn('h-3 w-3', colorClasses.text)} />
<DynamicIcon
name={iconName}
className={cn('h-3 w-3', colorClasses.text)}
/>
</div>
);
});
const DynamicIcon = memo(function DynamicIcon({ name, className }: { name?: string; className?: string }) {
const DynamicIcon = memo(function DynamicIcon({
name,
className,
}: {
name?: string;
className?: string;
}) {
const Icon = getIconComponent(name);
if (!Icon) return null;
return <Icon className={className} />;
});

View File

@ -74,4 +74,3 @@ export function SyncStatusButton() {
</TooltipProvider>
);
}

View File

@ -35,8 +35,8 @@ export function BulkActionsBar({
}
return (
<div className="fixed bottom-6 w-full flex items-center justify-center animate-in duration-300 fade-in fade-out slide-in-from-bottom-5 slide-out-to-bottom-5">
<div className="flex flex-row max-w-[75%] items-center justify-between gap-10 rounded-full border bg-background px-4 py-2 shadow-lg">
<div className="fixed bottom-6 flex w-full animate-in items-center justify-center duration-300 slide-in-from-bottom-5 slide-out-to-bottom-5 fade-in fade-out">
<div className="flex max-w-[75%] flex-row items-center justify-between gap-10 rounded-full border bg-background px-4 py-2 shadow-lg">
<div className="pl-2 text-sm">
{selectedCount} transaction{selectedCount !== 1 ? 's' : ''}{' '}
selected

View File

@ -65,7 +65,9 @@ export function EditTransactionDialog({
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
Map<string, string>
>(new Map());
const [automationRules, setAutomationRules] = useState<AutomationRule[]>([]);
const [automationRules, setAutomationRules] = useState<AutomationRule[]>(
[],
);
useEffect(() => {
if (mode === 'edit' && transaction) {
@ -145,7 +147,12 @@ export function EditTransactionDialog({
async function checkAndApplyAutomationRules() {
if (mode !== 'create' || automationRules.length === 0) {
return { categoryId: null, notes: null, notesIv: null, ruleName: null };
return {
categoryId: null,
notes: null,
notesIv: null,
ruleName: null,
};
}
const result = evaluateRulesForNewTransaction(
@ -163,7 +170,12 @@ export function EditTransactionDialog({
);
if (!result) {
return { categoryId: null, notes: null, notesIv: null, ruleName: null };
return {
categoryId: null,
notes: null,
notesIv: null,
ruleName: null,
};
}
let finalNotes = notes.trim();
@ -222,7 +234,10 @@ export function EditTransactionDialog({
toast.error('Date is required');
return;
}
} else if (mode === 'edit' && transaction?.source === 'manually_created') {
} else if (
mode === 'edit' &&
transaction?.source === 'manually_created'
) {
if (!description.trim()) {
toast.error('Description is required');
return;
@ -288,8 +303,8 @@ export function EditTransactionDialog({
const updatedCategory = finalCategoryId
? categories.find(
(category) => category.id === finalCategoryId,
) || null
(category) => category.id === finalCategoryId,
) || null
: null;
const newTransaction: DecryptedTransaction = {
@ -315,7 +330,8 @@ export function EditTransactionDialog({
return;
}
const selectedCategoryId = categoryId === 'null' ? null : categoryId;
const selectedCategoryId =
categoryId === 'null' ? null : categoryId;
const trimmedNotes = notes.trim();
const trimmedDescription = description.trim();
@ -340,10 +356,17 @@ export function EditTransactionDialog({
notes_iv: notesIv,
};
let finalDecryptedDescription = transaction.decryptedDescription;
let finalDecryptedDescription =
transaction.decryptedDescription;
if (transaction.source === 'manually_created' && trimmedDescription) {
const encryptedDescription = await encrypt(trimmedDescription, key);
if (
transaction.source === 'manually_created' &&
trimmedDescription
) {
const encryptedDescription = await encrypt(
trimmedDescription,
key,
);
updateData.description = encryptedDescription.encrypted;
updateData.description_iv = encryptedDescription.iv;
finalDecryptedDescription = trimmedDescription;
@ -356,8 +379,8 @@ export function EditTransactionDialog({
);
const updatedCategory = selectedCategoryId
? categories.find(
(category) => category.id === selectedCategoryId,
) || null
(category) => category.id === selectedCategoryId,
) || null
: null;
const updatedTransaction: DecryptedTransaction = {
@ -365,8 +388,10 @@ export function EditTransactionDialog({
category_id: selectedCategoryId,
category: updatedCategory,
decryptedDescription: finalDecryptedDescription,
description: updateData.description ?? transaction.description,
description_iv: updateData.description_iv ?? transaction.description_iv,
description:
updateData.description ?? transaction.description,
description_iv:
updateData.description_iv ?? transaction.description_iv,
decryptedNotes: trimmedNotes || null,
notes: encryptedNotes,
notes_iv: notesIv,
@ -388,9 +413,7 @@ export function EditTransactionDialog({
}
}
const selectedAccount = accounts.find(
(acc) => acc.id === accountId,
);
const selectedAccount = accounts.find((acc) => acc.id === accountId);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -458,14 +481,17 @@ export function EditTransactionDialog({
<Label
htmlFor="description"
className={
mode === 'edit' && transaction?.source === 'imported'
mode === 'edit' &&
transaction?.source === 'imported'
? 'text-sm text-muted-foreground'
: ''
}
>
Description
</Label>
{mode === 'create' || (mode === 'edit' && transaction?.source === 'manually_created') ? (
{mode === 'create' ||
(mode === 'edit' &&
transaction?.source === 'manually_created') ? (
<Textarea
id="description"
value={description}
@ -481,13 +507,18 @@ export function EditTransactionDialog({
<div className="space-y-1.5">
<Textarea
id="description"
value={transaction?.decryptedDescription ?? ''}
value={
transaction?.decryptedDescription ??
''
}
disabled
className="bg-muted"
rows={3}
/>
<p className="text-xs text-muted-foreground">
This transaction was imported from a file. The description cannot be modified.
This transaction was imported from a
file. The description cannot be
modified.
</p>
</div>
)}
@ -592,8 +623,8 @@ export function EditTransactionDialog({
{isSubmitting
? 'Saving...'
: mode === 'create'
? 'Create Transaction'
: 'Save Changes'}
? 'Create Transaction'
: 'Save Changes'}
</Button>
</DialogFooter>
</form>

View File

@ -23,7 +23,10 @@ interface ImportStepMappingProps {
dateFormatDetected: boolean;
parsedData: ParsedRow[];
currencyCode: string;
onMappingChange: (field: keyof ColumnMapping, value: string | string[]) => void;
onMappingChange: (
field: keyof ColumnMapping,
value: string | string[],
) => void;
onDateFormatChange: (format: DateFormat) => void;
onNext: () => void;
onBack: () => void;
@ -69,7 +72,7 @@ export function ImportStepMapping({
const handleDescriptionChange = (index: number, value: string) => {
const newColumns = [...descriptionColumns];
if (value === '__none__') {
newColumns.splice(index, 1);
} else {
@ -166,7 +169,9 @@ export function ImportStepMapping({
{descriptionColumns.length === 0 ? (
<Select
value=""
onValueChange={(value) => handleDescriptionChange(0, value)}
onValueChange={(value) =>
handleDescriptionChange(0, value)
}
>
<SelectTrigger id="description-column-0">
<SelectValue placeholder="Select description column" />
@ -194,7 +199,10 @@ export function ImportStepMapping({
<Select
value={column || ''}
onValueChange={(value) =>
handleDescriptionChange(columnIndex, value)
handleDescriptionChange(
columnIndex,
value,
)
}
>
<SelectTrigger
@ -214,23 +222,29 @@ export function ImportStepMapping({
None (Remove)
</SelectItem>
)}
{columnOptions.map((option, index) => (
<SelectItem
key={`desc-${columnIndex}-${option.value}-${index}`}
value={option.value}
>
<div className="flex flex-col">
<span>{option.label}</span>
</div>
</SelectItem>
))}
{columnOptions.map(
(option, index) => (
<SelectItem
key={`desc-${columnIndex}-${option.value}-${index}`}
value={option.value}
>
<div className="flex flex-col">
<span>
{option.label}
</span>
</div>
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
))}
{descriptionColumns.length > 0 &&
descriptionColumns.length < 3 &&
descriptionColumns[descriptionColumns.length - 1] !== '' && (
descriptionColumns[
descriptionColumns.length - 1
] !== '' && (
<Button
type="button"
variant="outline"
@ -272,13 +286,14 @@ export function ImportStepMapping({
</div>
<div className="space-y-2">
<Label htmlFor="balance-column">
Balance (Optional)
</Label>
<Label htmlFor="balance-column">Balance (Optional)</Label>
<Select
value={columnMapping.balance || '__none__'}
onValueChange={(value) =>
onMappingChange('balance', value === '__none__' ? '' : value)
onMappingChange(
'balance',
value === '__none__' ? '' : value,
)
}
>
<SelectTrigger id="balance-column">
@ -307,10 +322,10 @@ export function ImportStepMapping({
{previewTransactions.map((transaction, index) => (
<div
key={index}
className="flex items-start justify-between rounded-md bg-background p-3 text-sm gap-3"
className="flex items-start justify-between gap-3 rounded-md bg-background p-3 text-sm"
>
<div className="flex flex-1 items-start gap-3">
<span className="text-muted-foreground whitespace-nowrap">
<span className="whitespace-nowrap text-muted-foreground">
{transaction.date}
</span>
<span className="flex-1 whitespace-pre-line">

View File

@ -6,24 +6,12 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { Upload } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
interface ImportTransactionsButtonProps {
categories: Category[];
accounts: Account[];
banks: Bank[];
}
export function ImportTransactionsButton({
categories,
accounts,
banks,
}: ImportTransactionsButtonProps) {
export function ImportTransactionsButton() {
const { isKeySet } = useEncryptionKey();
const [drawerOpen, setDrawerOpen] = useState(false);
@ -63,9 +51,6 @@ export function ImportTransactionsButton({
<ImportTransactionsDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
categories={categories}
accounts={accounts}
banks={banks}
/>
</>
);

View File

@ -6,6 +6,7 @@ import {
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import { Progress } from '@/components/ui/progress';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { importKey } from '@/lib/crypto';
import {
@ -32,7 +33,6 @@ import {
type ColumnMapping,
type ImportState,
} from '@/types/import';
import { Progress } from '@/components/ui/progress';
import { Check } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -44,9 +44,6 @@ import { ImportStepUpload } from './import-step-upload';
interface ImportTransactionsDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
categories: import('@/types/category').Category[];
accounts: import('@/types/account').Account[];
banks: import('@/types/account').Bank[];
}
interface ImportError {
@ -62,9 +59,6 @@ interface ImportError {
export function ImportTransactionsDrawer({
open,
onOpenChange,
categories,
accounts,
banks,
}: ImportTransactionsDrawerProps) {
const { isKeySet } = useEncryptionKey();
const [isImporting, setIsImporting] = useState(false);
@ -400,7 +394,11 @@ export function ImportTransactionsDrawer({
const createdTransaction =
await transactionSyncService.create(transactionData);
return { success: true, transaction: createdTransaction, rowNumber };
return {
success: true,
transaction: createdTransaction,
rowNumber,
};
}),
);
@ -415,14 +413,17 @@ export function ImportTransactionsDrawer({
result.reason instanceof Error
? result.reason.message
: 'Unknown error';
console.error(`Transaction ${rowNumber} failed:`, {
transaction,
error: result.reason,
errorMessage,
stack: result.reason instanceof Error ? result.reason.stack : undefined,
stack:
result.reason instanceof Error
? result.reason.stack
: undefined,
});
errors.push({
rowNumber,
transaction: {
@ -441,20 +442,26 @@ export function ImportTransactionsDrawer({
const balancesToImport = new Map<string, number>();
for (const transaction of newTransactions) {
if (transaction.balance !== null && transaction.balance !== undefined) {
balancesToImport.set(transaction.transaction_date, transaction.balance);
if (
transaction.balance !== null &&
transaction.balance !== undefined
) {
balancesToImport.set(
transaction.transaction_date,
transaction.balance,
);
}
}
if (balancesToImport.size > 0) {
try {
const balanceRecords = Array.from(balancesToImport.entries()).map(
([date, balance]) => ({
account_id: selectedAccount.id,
balance_date: date,
balance,
}),
);
const balanceRecords = Array.from(
balancesToImport.entries(),
).map(([date, balance]) => ({
account_id: selectedAccount.id,
balance_date: date,
balance,
}));
await accountBalanceSyncService.createMany(balanceRecords);
} catch (err) {
@ -495,7 +502,10 @@ export function ImportTransactionsDrawer({
}
transactionSyncService.sync().catch((syncError) => {
console.error('Failed to sync transactions with backend:', syncError);
console.error(
'Failed to sync transactions with backend:',
syncError,
);
});
accountBalanceSyncService.sync().catch((syncError) => {
@ -536,7 +546,8 @@ export function ImportTransactionsDrawer({
if (isImporting) {
return {
title: 'Importing Transactions',
description: 'Please wait while we import your transactions',
description:
'Please wait while we import your transactions',
};
}
@ -597,14 +608,16 @@ export function ImportTransactionsDrawer({
};
const renderImportProgress = () => {
const percentage = importTotal > 0 ? (importProgress / importTotal) * 100 : 0;
const percentage =
importTotal > 0 ? (importProgress / importTotal) * 100 : 0;
return (
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{importProgress} of {importTotal} transactions imported
{importProgress} of {importTotal} transactions
imported
</span>
<span>{Math.round(percentage)}%</span>
</div>
@ -648,13 +661,13 @@ export function ImportTransactionsDrawer({
<td className="px-4 py-2">
{error.transaction.date}
</td>
<td className="px-4 py-2 max-w-[200px] truncate">
<td className="max-w-[200px] truncate px-4 py-2">
{error.transaction.description}
</td>
<td className="px-4 py-2 font-mono">
{error.transaction.amount}
</td>
<td className="px-4 py-2 text-destructive max-w-[200px] truncate">
<td className="max-w-[200px] truncate px-4 py-2 text-destructive">
{error.error}
</td>
</tr>

View File

@ -134,7 +134,7 @@ export function createTransactionColumns({
cell: ({ row }) => {
const bank = row.original.bank;
return (
<div className="flex px-1 items-center gap-2">
<div className="flex items-center gap-2 px-1">
{bank?.logo && (
<img
src={bank.logo}
@ -154,7 +154,7 @@ export function createTransactionColumns({
cell: ({ row }) => {
const account = row.original.account;
if (!account) {
return <div className='px-1'>N/A</div>;
return <div className="px-1">N/A</div>;
}
return (
@ -162,7 +162,7 @@ export function createTransactionColumns({
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 5, max: 10 }}
className="max-w-[100px] px-1 truncate"
className="max-w-[100px] truncate px-1"
/>
);
},
@ -185,7 +185,7 @@ export function createTransactionColumns({
}).format(amount);
return (
<div className={`text-right pl-4`}>
<div className={`pl-4 text-right`}>
<span
className={`${amount < 0 ? '' : 'bg-green-100/70 dark:bg-green-900'}`}
>
@ -202,14 +202,20 @@ export function createTransactionColumns({
maxSize: 35,
minSize: 35,
meta: {
cellClassName: '!w-[35px] !max-w-[35px] !min-w-[35px] !p-0 whitespace-normal',
cellStyle: { width: '35px', maxWidth: '35px', minWidth: '35px', padding: 0 }
cellClassName:
'!w-[35px] !max-w-[35px] !min-w-[35px] !p-0 whitespace-normal',
cellStyle: {
width: '35px',
maxWidth: '35px',
minWidth: '35px',
padding: 0,
},
},
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex items-center justify-center w-[35px]">
<div className="flex w-[35px] items-center justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@ -225,7 +231,9 @@ export function createTransactionColumns({
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onReEvaluateRules(transaction)}
onClick={() =>
onReEvaluateRules(transaction)
}
>
Re-evaluate rules
</DropdownMenuItem>

View File

@ -1,6 +1,6 @@
import { format } from 'date-fns';
import { Check, ChevronsUpDown, CircleHelp, X } from 'lucide-react';
import * as Icons from 'lucide-react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { EncryptedText } from '@/components/encrypted-text';
@ -119,7 +119,10 @@ export function TransactionFilters({
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 max-h-[600px] overflow-y-auto" align="start">
<PopoverContent
className="max-h-[600px] w-96 overflow-y-auto"
align="start"
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">Filters</h4>
@ -133,9 +136,9 @@ export function TransactionFilters({
value={
filters.dateFrom
? format(
filters.dateFrom,
'yyyy-MM-dd',
)
filters.dateFrom,
'yyyy-MM-dd',
)
: ''
}
onChange={(e) =>
@ -153,9 +156,9 @@ export function TransactionFilters({
value={
filters.dateTo
? format(
filters.dateTo,
'yyyy-MM-dd',
)
filters.dateTo,
'yyyy-MM-dd',
)
: ''
}
onChange={(e) =>
@ -218,9 +221,13 @@ export function TransactionFilters({
role="combobox"
className="w-full justify-between"
>
{filters.categoryIds.length > 0 ? (
{filters.categoryIds.length >
0 ? (
<span className="truncate">
{filters.categoryIds.length}{' '}
{
filters.categoryIds
.length
}{' '}
selected
</span>
) : (
@ -231,7 +238,10 @@ export function TransactionFilters({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<PopoverContent
className="w-full p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search categories..." />
<CommandList>
@ -247,7 +257,7 @@ export function TransactionFilters({
>
<div
className={cn(
'mr-1 flex p-1 size-4 items-center justify-center rounded-sm border border-primary',
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isUncategorizedSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
@ -262,50 +272,63 @@ export function TransactionFilters({
Uncategorized
</div>
</CommandItem>
{categories.map((category) => {
const isSelected =
filters.categoryIds.includes(
category.id,
);
const colorClasses =
getCategoryColorClasses(
category.color,
);
return (
<CommandItem
key={category.id}
onSelect={() =>
handleCategoryToggle(
category.id,
)
}
>
<div
className={cn(
'mr-1 flex p-1 size-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
{categories.map(
(category) => {
const isSelected =
filters.categoryIds.includes(
category.id,
);
const colorClasses =
getCategoryColorClasses(
category.color,
);
return (
<CommandItem
key={
category.id
}
onSelect={() =>
handleCategoryToggle(
category.id,
)
}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<CategoryIcon category={category} size={4} />
<Check className="size-3" />
</div>
<span>
{category.name}
</span>
</div>
</CommandItem>
);
})}
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<CategoryIcon
category={
category
}
size={
4
}
/>
</div>
<span>
{
category.name
}
</span>
</div>
</CommandItem>
);
},
)}
</CommandList>
</Command>
</PopoverContent>
@ -372,12 +395,3 @@ export function TransactionFilters({
}
const UNCATEGORIZED_CATEGORY_ID = -1;
function getIconComponent(iconName?: string): Icons.LucideIcon | null {
if (!iconName) return null;
const icon = Icons[iconName as keyof typeof Icons] as Icons.LucideIcon | undefined;
if (icon && typeof icon === 'function') {
return icon;
}
return null;
}

View File

@ -34,7 +34,10 @@ function formatErrorMessage(error: string): string {
if (error.includes('status code 5')) {
return 'Server is temporarily unavailable. Please try again later.';
}
if (error.includes('status code 401') || error.includes('status code 403')) {
if (
error.includes('status code 401') ||
error.includes('status code 403')
) {
return 'Your session has expired. Please refresh the page.';
}
if (error.includes('status code 4')) {
@ -103,7 +106,9 @@ export function SyncProvider({
}
if (!isOnline) {
setError('Unable to sync while offline. Connect to the internet and try again.');
setError(
'Unable to sync while offline. Connect to the internet and try again.',
);
return;
}

View File

@ -70,7 +70,6 @@ export function useAppearance() {
'appearance',
) as Appearance | null;
updateAppearance(savedAppearance || 'system');
return () =>

View File

@ -1,6 +1,6 @@
import { Account } from '@/types/account';
import { Category } from '@/types/category';
import { endOfMonth, format, startOfMonth, subDays, subMonths } from 'date-fns';
import { format, subDays, subMonths } from 'date-fns';
import { useEffect, useState } from 'react';
export interface DashboardData {
@ -37,14 +37,15 @@ export interface DashboardData {
isLoading: boolean;
}
export function useDashboardData(
categories: Category[],
accounts: Account[],
): DashboardData {
export function useDashboardData(): DashboardData {
const [data, setData] = useState<Omit<DashboardData, 'isLoading'>>({
netWorth: { current: 0, previous: 0, diff: 0 },
monthlySpending: { current: 0, limit: null, previous: 0 },
cashFlow: { income: 0, expense: 0, previous: { income: 0, expense: 0 } },
cashFlow: {
income: 0,
expense: 0,
previous: { income: 0, expense: 0 },
},
netWorthHistory: [],
accounts: [],
topCategories: [],
@ -57,15 +58,21 @@ export function useDashboardData(
try {
const now = new Date();
const to = format(now, 'yyyy-MM-dd');
// Last 12 months for evolution charts
const from12Months = format(subMonths(now, 12), 'yyyy-MM-dd');
const params12Months = new URLSearchParams({ from: from12Months, to });
const params12Months = new URLSearchParams({
from: from12Months,
to,
});
const query12Months = `?${params12Months.toString()}`;
// Last 30 days for top blocks and categories
const from30Days = format(subDays(now, 30), 'yyyy-MM-dd');
const params30Days = new URLSearchParams({ from: from30Days, to });
const params30Days = new URLSearchParams({
from: from30Days,
to,
});
const query30Days = `?${params30Days.toString()}`;
const [
@ -74,14 +81,26 @@ export function useDashboardData(
cashFlow,
netWorthEvolution,
accountBalances,
topCategories
topCategories,
] = await Promise.all([
fetch(`/api/dashboard/net-worth${query30Days}`).then(r => r.json()),
fetch(`/api/dashboard/monthly-spending${query30Days}`).then(r => r.json()),
fetch(`/api/dashboard/cash-flow${query30Days}`).then(r => r.json()),
fetch(`/api/dashboard/net-worth-evolution${query12Months}`).then(r => r.json()),
fetch(`/api/dashboard/account-balances${query12Months}`).then(r => r.json()),
fetch(`/api/dashboard/top-categories${query30Days}`).then(r => r.json()),
fetch(`/api/dashboard/net-worth${query30Days}`).then((r) =>
r.json(),
),
fetch(`/api/dashboard/monthly-spending${query30Days}`).then(
(r) => r.json(),
),
fetch(`/api/dashboard/cash-flow${query30Days}`).then((r) =>
r.json(),
),
fetch(
`/api/dashboard/net-worth-evolution${query12Months}`,
).then((r) => r.json()),
fetch(
`/api/dashboard/account-balances${query12Months}`,
).then((r) => r.json()),
fetch(`/api/dashboard/top-categories${query30Days}`).then(
(r) => r.json(),
),
]);
setData({
@ -101,12 +120,19 @@ export function useDashboardData(
previous: cashFlow.previous,
},
netWorthHistory: netWorthEvolution,
accounts: accountBalances.map((acc: any) => ({
...acc,
currentBalance: acc.current_balance,
previousBalance: acc.previous_balance,
diff: acc.current_balance - acc.previous_balance,
})),
accounts: accountBalances.map(
(acc: {
id: string;
name: string;
current_balance: number;
previous_balance: number;
}) => ({
...acc,
currentBalance: acc.current_balance,
previousBalance: acc.previous_balance,
diff: acc.current_balance - acc.previous_balance,
}),
),
topCategories,
});
} catch (error) {

View File

@ -21,4 +21,3 @@ export function useOnlineStatus() {
return isOnline;
}

View File

@ -107,12 +107,13 @@ export function useReEvaluateAllTransactions() {
}
toast.dismiss(toastId);
toast.success(
() => (<div>
{`Re-evaluation complete!`}<br />
toast.success(() => (
<div>
{`Re-evaluation complete!`}
<br />
{`${successCount} transaction(s) updated.`}
</div >)
);
</div>
));
} catch (error) {
console.error('Failed to re-evaluate transactions:', error);
toast.error(

View File

@ -1,2 +1 @@
export const LEAD_FUNNEL_EVENT_UUID = '9668a06c-dee9-47a8-9bee-eaaa2a3a5915';

View File

@ -309,7 +309,10 @@ export function autoDetectColumns(headers: string[]): ColumnMapping {
mapping.amount = originalHeader;
}
if (!mapping.balance && balancePatterns.some((p) => header.includes(p))) {
if (
!mapping.balance &&
balancePatterns.some((p) => header.includes(p))
) {
mapping.balance = originalHeader;
}
}
@ -527,7 +530,9 @@ export function convertRowsToTransactions(
let balance: number | null = null;
if (mapping.balance && row[mapping.balance]) {
const parsedBalance = parseAmount(row[mapping.balance] as string | number);
const parsedBalance = parseAmount(
row[mapping.balance] as string | number,
);
if (parsedBalance !== null) {
balance = Math.round(parsedBalance * 100);
}

View File

@ -75,13 +75,17 @@ export function prepareTransactionData(
};
return {
description: normalizeWhitespace((transaction.decryptedDescription || '').toLowerCase()),
description: normalizeWhitespace(
(transaction.decryptedDescription || '').toLowerCase(),
),
amount: transaction.amount / 100,
transaction_date: transaction.transaction_date,
bank_name: bank?.name || '',
account_name: account?.name || '',
category: category?.name || null,
notes: transaction.decryptedNotes ? normalizeWhitespace(transaction.decryptedNotes.toLowerCase()) : null,
notes: transaction.decryptedNotes
? normalizeWhitespace(transaction.decryptedNotes.toLowerCase())
: null,
};
}
@ -188,16 +192,19 @@ export function evaluateRulesForNewTransaction(
banks: Bank[],
): RuleEvaluationResult | null {
if (!rules || !categories || !accounts || !banks) {
consoleDebug('[Rule Engine] Missing required data for rule evaluation', {
hasRules: !!rules,
rulesLength: rules?.length,
hasCategories: !!categories,
categoriesLength: categories?.length,
hasAccounts: !!accounts,
accountsLength: accounts?.length,
hasBanks: !!banks,
banksLength: banks?.length,
});
consoleDebug(
'[Rule Engine] Missing required data for rule evaluation',
{
hasRules: !!rules,
rulesLength: rules?.length,
hasCategories: !!categories,
categoriesLength: categories?.length,
hasAccounts: !!accounts,
accountsLength: accounts?.length,
hasBanks: !!banks,
banksLength: banks?.length,
},
);
return null;
}
@ -213,16 +220,23 @@ export function evaluateRulesForNewTransaction(
};
const preparedData: TransactionData = {
description: normalizeWhitespace(transactionData.description.toLowerCase()),
description: normalizeWhitespace(
transactionData.description.toLowerCase(),
),
amount: transactionData.amount,
transaction_date: transactionData.transaction_date,
bank_name: bank?.name || '',
account_name: account?.name || '',
category: null,
notes: transactionData.notes ? normalizeWhitespace(transactionData.notes.toLowerCase()) : null,
notes: transactionData.notes
? normalizeWhitespace(transactionData.notes.toLowerCase())
: null,
};
consoleDebug('[Rule Engine] Evaluating new transaction data:', preparedData);
consoleDebug(
'[Rule Engine] Evaluating new transaction data:',
preparedData,
);
consoleDebug(`[Rule Engine] Evaluating ${sortedRules.length} rules`);
for (const rule of sortedRules) {
@ -238,10 +252,7 @@ export function evaluateRulesForNewTransaction(
normalizedRulesJson,
);
const result = jsonLogic.apply(
normalizedRulesJson,
preparedData,
);
const result = jsonLogic.apply(normalizedRulesJson, preparedData);
consoleDebug(`[Rule Engine] Rule #${rule.id} result:`, result);

View File

@ -1,5 +1,5 @@
import axios from 'axios';
import type { UUID } from '@/types/uuid';
import axios from 'axios';
import { uuidv7 } from 'uuidv7';
import { db } from './dexie-db';
@ -21,8 +21,12 @@ export interface IndexedDBRecord {
export interface SyncOptions {
storeName: StoreName;
endpoint: string;
transformFromServer?: (data: Record<string, unknown>) => Record<string, unknown>;
transformToServer?: (data: Record<string, unknown>) => Record<string, unknown>;
transformFromServer?: (
data: Record<string, unknown>,
) => Record<string, unknown>;
transformToServer?: (
data: Record<string, unknown>,
) => Record<string, unknown>;
}
export interface SyncResult {
@ -114,7 +118,12 @@ export class SyncManager {
const table = db[this.options.storeName];
const localRecords = await table.toArray();
const localMap = new Map(localRecords.map((r) => [(r as IndexedDBRecord).id, r as IndexedDBRecord]));
const localMap = new Map(
localRecords.map((r) => [
(r as IndexedDBRecord).id,
r as IndexedDBRecord,
]),
);
const toInsert: Record<string, unknown>[] = [];
const toUpdate: Record<string, unknown>[] = [];
@ -268,4 +277,3 @@ export class SyncManager {
return this.syncInProgress;
}
}

View File

@ -1,5 +1,5 @@
import { usePage } from '@inertiajs/react';
import type { SharedData } from '@/types';
import { usePage } from '@inertiajs/react';
export interface TrackEventPayload {
step: string;
@ -59,7 +59,11 @@ export async function trackEvent(
export function useTrackEvent() {
const { auth } = usePage<SharedData>().props;
return (eventUuid: string, payload: Omit<TrackEventPayload, 'user_id'>, options?: TrackEventOptions) => {
return (
eventUuid: string,
payload: Omit<TrackEventPayload, 'user_id'>,
options?: TrackEventOptions,
) => {
const finalPayload: TrackEventPayload = {
...payload,
user_id: auth?.user?.id,

View File

@ -32,11 +32,16 @@ function isCryptoAvailable(): boolean {
return !!(window.crypto && window.crypto.subtle);
}
function getInitialErrors(): { password?: string; confirmPassword?: string; general?: string } {
function getInitialErrors(): {
password?: string;
confirmPassword?: string;
general?: string;
} {
if (typeof window === 'undefined') return {};
if (!window.crypto || !window.crypto.subtle) {
return {
general: 'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
general:
'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
};
}
return {};

View File

@ -9,16 +9,8 @@ import { useDashboardData } from '@/hooks/use-dashboard-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { dashboard } from '@/routes';
import { BreadcrumbItem } from '@/types';
import { Account, Bank } from '@/types/account';
import { Category } from '@/types/category';
import { Head } from '@inertiajs/react';
interface Props {
categories: Category[];
accounts: Account[];
banks: Bank[];
}
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
@ -26,7 +18,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
export default function Dashboard({ categories, accounts }: Props) {
export default function Dashboard() {
const {
netWorth,
monthlySpending,
@ -35,7 +27,7 @@ export default function Dashboard({ categories, accounts }: Props) {
accounts: accountMetrics,
topCategories,
isLoading,
} = useDashboardData(categories, accounts);
} = useDashboardData();
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>

View File

@ -1,5 +1,5 @@
import { Head, Link, usePage } from '@inertiajs/react';
import { type SharedData } from '@/types';
import { Head, Link, usePage } from '@inertiajs/react';
export default function Privacy() {
const { appUrl } = usePage<SharedData>().props;
@ -14,7 +14,10 @@ export default function Privacy() {
<link rel="canonical" href={`${appUrl}/privacy`} />
<meta name="robots" content="index, follow" />
<meta property="og:title" content="Privacy Policy - Whisper Money" />
<meta
property="og:title"
content="Privacy Policy - Whisper Money"
/>
<meta
property="og:description"
content="Privacy policy for Whisper Money. Learn how we collect, use, and protect your personal information."
@ -35,7 +38,7 @@ export default function Privacy() {
Privacy Policy
</h1>
<div className="prose prose-neutral max-w-none dark:prose-invert">
<div className="prose prose-neutral dark:prose-invert max-w-none">
<p className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
Last updated: {new Date().toLocaleDateString()}
</p>
@ -45,12 +48,20 @@ export default function Privacy() {
1. Data Controller
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
Whisper Money is the data controller responsible for your personal information.
Whisper Money is the data controller responsible
for your personal information.
</p>
<div className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
<p><strong>Company Name:</strong> Whisper Money</p>
<p><strong>Address:</strong> Calle Oca, Madrid - 28025, Spain</p>
<p><strong>Email:</strong> victor@whisper.money</p>
<p>
<strong>Company Name:</strong> Whisper Money
</p>
<p>
<strong>Address:</strong> Calle Oca, Madrid
- 28025, Spain
</p>
<p>
<strong>Email:</strong> victor@whisper.money
</p>
</div>
</section>
@ -59,13 +70,30 @@ export default function Privacy() {
2. Information We Collect
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We collect the following types of personal information:
We collect the following types of personal
information:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li><strong>Account Information:</strong> Email address, name, and password (encrypted)</li>
<li><strong>Financial Data:</strong> Transaction details, budgets, categories, and other financial information you manually enter into the application</li>
<li><strong>Usage Information:</strong> Information about how you use our service, including access times and features used</li>
<li><strong>Technical Information:</strong> IP address, browser type, device information, and operating system</li>
<li>
<strong>Account Information:</strong> Email
address, name, and password (encrypted)
</li>
<li>
<strong>Financial Data:</strong> Transaction
details, budgets, categories, and other
financial information you manually enter
into the application
</li>
<li>
<strong>Usage Information:</strong>{' '}
Information about how you use our service,
including access times and features used
</li>
<li>
<strong>Technical Information:</strong> IP
address, browser type, device information,
and operating system
</li>
</ul>
</section>
@ -74,20 +102,45 @@ export default function Privacy() {
3. How We Use Your Information
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We process your personal data for the following purposes:
We process your personal data for the following
purposes:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li>To provide and maintain our personal finance tracking service</li>
<li>To enable cloud synchronization of your encrypted financial data across devices</li>
<li>To authenticate your access and protect your account security</li>
<li>To process payments for premium features or subscriptions</li>
<li>To send you service-related notifications, updates, and security alerts via email</li>
<li>To improve and optimize our service based on usage patterns</li>
<li>To comply with legal obligations and enforce our terms</li>
<li>
To provide and maintain our personal finance
tracking service
</li>
<li>
To enable cloud synchronization of your
encrypted financial data across devices
</li>
<li>
To authenticate your access and protect your
account security
</li>
<li>
To process payments for premium features or
subscriptions
</li>
<li>
To send you service-related notifications,
updates, and security alerts via email
</li>
<li>
To improve and optimize our service based on
usage patterns
</li>
<li>
To comply with legal obligations and enforce
our terms
</li>
</ul>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
The legal basis for processing your data includes: performance of our contract with you,
your consent, our legitimate interests in improving the service, and compliance with legal obligations.
The legal basis for processing your data
includes: performance of our contract with you,
your consent, our legitimate interests in
improving the service, and compliance with legal
obligations.
</p>
</section>
@ -96,18 +149,40 @@ export default function Privacy() {
4. Data Security and Encryption
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We implement robust security measures to protect your personal information:
We implement robust security measures to protect
your personal information:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li><strong>End-to-End Encryption:</strong> Your financial data is encrypted on your device before being transmitted to our servers, ensuring that only you can access your information</li>
<li><strong>Secure Storage:</strong> All data is stored on secure servers with encryption at rest</li>
<li><strong>Access Controls:</strong> Strict access controls and authentication mechanisms protect against unauthorized access</li>
<li><strong>Regular Security Audits:</strong> We regularly review and update our security practices</li>
<li>
<strong>End-to-End Encryption:</strong> Your
financial data is encrypted on your device
before being transmitted to our servers,
ensuring that only you can access your
information
</li>
<li>
<strong>Secure Storage:</strong> All data is
stored on secure servers with encryption at
rest
</li>
<li>
<strong>Access Controls:</strong> Strict
access controls and authentication
mechanisms protect against unauthorized
access
</li>
<li>
<strong>Regular Security Audits:</strong> We
regularly review and update our security
practices
</li>
</ul>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
While we use industry-standard security measures, no method of transmission over the internet
or electronic storage is 100% secure. We cannot guarantee absolute security but continuously
work to protect your data.
While we use industry-standard security
measures, no method of transmission over the
internet or electronic storage is 100% secure.
We cannot guarantee absolute security but
continuously work to protect your data.
</p>
</section>
@ -116,14 +191,28 @@ export default function Privacy() {
5. Third-Party Services
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We use the following third-party services to operate our platform:
We use the following third-party services to
operate our platform:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li><strong>Payment Processors:</strong> To process subscription payments and transactions. These processors have access only to the information necessary to perform their functions and are obligated to protect your data</li>
<li><strong>Email Service Providers:</strong> To send transactional emails, password resets, and service notifications</li>
<li>
<strong>Payment Processors:</strong> To
process subscription payments and
transactions. These processors have access
only to the information necessary to perform
their functions and are obligated to protect
your data
</li>
<li>
<strong>Email Service Providers:</strong> To
send transactional emails, password resets,
and service notifications
</li>
</ul>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We do not sell, trade, or rent your personal information to third parties for marketing purposes.
We do not sell, trade, or rent your personal
information to third parties for marketing
purposes.
</p>
</section>
@ -132,9 +221,12 @@ export default function Privacy() {
6. Data Retention
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We retain your personal information for as long as necessary to provide our services and fulfill
the purposes outlined in this Privacy Policy. When you delete your account, we will delete or
anonymize your personal information within 30 days, except where we are required to retain it
We retain your personal information for as long
as necessary to provide our services and fulfill
the purposes outlined in this Privacy Policy.
When you delete your account, we will delete or
anonymize your personal information within 30
days, except where we are required to retain it
for legal, accounting, or security purposes.
</p>
</section>
@ -144,10 +236,13 @@ export default function Privacy() {
7. International Data Transfers
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
Your data is primarily stored and processed within the European Union. If we transfer data
outside the EU, we ensure appropriate safeguards are in place, such as Standard Contractual
Clauses approved by the European Commission, to protect your information in accordance with
GDPR requirements.
Your data is primarily stored and processed
within the European Union. If we transfer data
outside the EU, we ensure appropriate safeguards
are in place, such as Standard Contractual
Clauses approved by the European Commission, to
protect your information in accordance with GDPR
requirements.
</p>
</section>
@ -156,21 +251,55 @@ export default function Privacy() {
8. Your Rights Under GDPR
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
As a user in the European Union, you have the following rights regarding your personal data:
As a user in the European Union, you have the
following rights regarding your personal data:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li><strong>Right of Access:</strong> You can request a copy of the personal data we hold about you</li>
<li><strong>Right to Rectification:</strong> You can request correction of inaccurate or incomplete data</li>
<li><strong>Right to Erasure:</strong> You can request deletion of your personal data (right to be forgotten)</li>
<li><strong>Right to Data Portability:</strong> You can request your data in a structured, machine-readable format</li>
<li><strong>Right to Restriction:</strong> You can request restriction of processing in certain circumstances</li>
<li><strong>Right to Object:</strong> You can object to processing of your data based on legitimate interests</li>
<li><strong>Right to Withdraw Consent:</strong> Where processing is based on consent, you can withdraw it at any time</li>
<li><strong>Right to Lodge a Complaint:</strong> You can file a complaint with your local data protection authority</li>
<li>
<strong>Right of Access:</strong> You can
request a copy of the personal data we hold
about you
</li>
<li>
<strong>Right to Rectification:</strong> You
can request correction of inaccurate or
incomplete data
</li>
<li>
<strong>Right to Erasure:</strong> You can
request deletion of your personal data
(right to be forgotten)
</li>
<li>
<strong>Right to Data Portability:</strong>{' '}
You can request your data in a structured,
machine-readable format
</li>
<li>
<strong>Right to Restriction:</strong> You
can request restriction of processing in
certain circumstances
</li>
<li>
<strong>Right to Object:</strong> You can
object to processing of your data based on
legitimate interests
</li>
<li>
<strong>Right to Withdraw Consent:</strong>{' '}
Where processing is based on consent, you
can withdraw it at any time
</li>
<li>
<strong>Right to Lodge a Complaint:</strong>{' '}
You can file a complaint with your local
data protection authority
</li>
</ul>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
To exercise any of these rights, please contact us at victor@whisper.money. We will respond
to your request within 30 days.
To exercise any of these rights, please contact
us at victor@whisper.money. We will respond to
your request within 30 days.
</p>
</section>
@ -179,9 +308,12 @@ export default function Privacy() {
9. Cookies and Tracking
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We use essential cookies to maintain your session and ensure the proper functioning of our
service. These cookies are necessary for the service to work and cannot be disabled. We do
not use tracking cookies or analytics cookies without your explicit consent.
We use essential cookies to maintain your
session and ensure the proper functioning of our
service. These cookies are necessary for the
service to work and cannot be disabled. We do
not use tracking cookies or analytics cookies
without your explicit consent.
</p>
</section>
@ -190,9 +322,11 @@ export default function Privacy() {
10. Children's Privacy
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
Our service is not intended for users under the age of 16. We do not knowingly collect
personal information from children. If you believe we have collected information from a
child, please contact us immediately, and we will delete it.
Our service is not intended for users under the
age of 16. We do not knowingly collect personal
information from children. If you believe we
have collected information from a child, please
contact us immediately, and we will delete it.
</p>
</section>
@ -201,11 +335,15 @@ export default function Privacy() {
11. Changes to This Privacy Policy
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We may update this Privacy Policy from time to time to reflect changes in our practices or
for legal, operational, or regulatory reasons. When we make material changes, we will notify
you by email and/or by posting a notice on our website at least 30 days before the changes
take effect. Your continued use of the service after changes become effective constitutes
acceptance of the updated policy.
We may update this Privacy Policy from time to
time to reflect changes in our practices or for
legal, operational, or regulatory reasons. When
we make material changes, we will notify you by
email and/or by posting a notice on our website
at least 30 days before the changes take effect.
Your continued use of the service after changes
become effective constitutes acceptance of the
updated policy.
</p>
</section>
@ -214,12 +352,18 @@ export default function Privacy() {
12. Contact Us
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
If you have any questions, concerns, or requests regarding this Privacy Policy or our data
If you have any questions, concerns, or requests
regarding this Privacy Policy or our data
practices, please contact us:
</p>
<div className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
<p><strong>Email:</strong> victor@whisper.money</p>
<p><strong>Address:</strong> Whisper Money, Calle Oca, Madrid - 28025, Spain</p>
<p>
<strong>Email:</strong> victor@whisper.money
</p>
<p>
<strong>Address:</strong> Whisper Money,
Calle Oca, Madrid - 28025, Spain
</p>
</div>
</section>
</div>
@ -228,4 +372,3 @@ export default function Privacy() {
</>
);
}

View File

@ -111,7 +111,13 @@ function AccountActions({
);
}
function AccountRow({ row, onSuccess }: { row: Row<Account>; onSuccess?: () => void }) {
function AccountRow({
row,
onSuccess,
}: {
row: Row<Account>;
onSuccess?: () => void;
}) {
const account = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -130,13 +136,9 @@ function AccountRow({ row, onSuccess }: { row: Row<Account>; onSuccess?: () => v
{row
.getVisibleCells()
.map((cell: Cell<Account, unknown>) => (
<TableCell
key={cell.id}
>
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef
.cell,
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
@ -348,13 +350,17 @@ export default function Accounts() {
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<AccountRow
key={row.id}
row={row}
onSuccess={handleAccountCreated}
/>
))
table
.getRowModel()
.rows.map((row) => (
<AccountRow
key={row.id}
row={row}
onSuccess={
handleAccountCreated
}
/>
))
) : (
<TableRow>
<TableCell

View File

@ -13,9 +13,9 @@ import {
VisibilityState,
} from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import { useMemo, useState } from 'react';
import { MoreHorizontal } from 'lucide-react';
import * as Icons from 'lucide-react';
import { MoreHorizontal } from 'lucide-react';
import { useMemo, useState } from 'react';
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
@ -122,13 +122,9 @@ function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
{row
.getVisibleCells()
.map((cell: Cell<AutomationRule, unknown>) => (
<TableCell
key={cell.id}
>
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef
.cell,
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
@ -353,9 +349,14 @@ export default function AutomationRules() {
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<AutomationRuleRow key={row.id} row={row} />
))
table
.getRowModel()
.rows.map((row) => (
<AutomationRuleRow
key={row.id}
row={row}
/>
))
) : (
<TableRow>
<TableCell

View File

@ -122,13 +122,9 @@ function CategoryRow({ row }: { row: Row<Category> }) {
{row
.getVisibleCells()
.map((cell: Cell<Category, unknown>) => (
<TableCell
key={cell.id}
>
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef
.cell,
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
@ -193,9 +189,9 @@ export default function Categories() {
},
cell: ({ row }) => {
const iconName = row.original.icon;
const IconComponent = Icons[
iconName as keyof typeof Icons
] as Icons.LucideIcon | undefined;
const IconComponent = Icons[iconName as keyof typeof Icons] as
| Icons.LucideIcon
| undefined;
const Icon = IconComponent || Icons.Tag;
return (
@ -313,9 +309,14 @@ export default function Categories() {
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<CategoryRow key={row.id} row={row} />
))
table
.getRowModel()
.rows.map((row) => (
<CategoryRow
key={row.id}
row={row}
/>
))
) : (
<TableRow>
<TableCell

View File

@ -1,5 +1,5 @@
import { Head, Link, usePage } from '@inertiajs/react';
import { type SharedData } from '@/types';
import { Head, Link, usePage } from '@inertiajs/react';
export default function Terms() {
const { appUrl } = usePage<SharedData>().props;
@ -14,7 +14,10 @@ export default function Terms() {
<link rel="canonical" href={`${appUrl}/terms`} />
<meta name="robots" content="index, follow" />
<meta property="og:title" content="Terms of Service - Whisper Money" />
<meta
property="og:title"
content="Terms of Service - Whisper Money"
/>
<meta
property="og:description"
content="Terms of service for Whisper Money. Review the rules and regulations for using our platform."
@ -35,7 +38,7 @@ export default function Terms() {
Terms of Service
</h1>
<div className="prose prose-neutral max-w-none dark:prose-invert">
<div className="prose prose-neutral dark:prose-invert max-w-none">
<p className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
Last updated: {new Date().toLocaleDateString()}
</p>
@ -45,12 +48,20 @@ export default function Terms() {
1. Service Provider
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
These Terms of Service govern your use of the Whisper Money personal finance platform.
These Terms of Service govern your use of the
Whisper Money personal finance platform.
</p>
<div className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
<p><strong>Company Name:</strong> Whisper Money</p>
<p><strong>Address:</strong> Calle Oca, Madrid - 28025, Spain</p>
<p><strong>Email:</strong> victor@whisper.money</p>
<p>
<strong>Company Name:</strong> Whisper Money
</p>
<p>
<strong>Address:</strong> Calle Oca, Madrid
- 28025, Spain
</p>
<p>
<strong>Email:</strong> victor@whisper.money
</p>
</div>
</section>
@ -59,13 +70,17 @@ export default function Terms() {
2. Acceptance of Terms
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
By creating an account or using Whisper Money, you agree to be bound by these Terms of
Service and our Privacy Policy. If you do not agree to these terms, you may not use our
service. These terms constitute a legally binding agreement between you and Whisper Money.
By creating an account or using Whisper Money,
you agree to be bound by these Terms of Service
and our Privacy Policy. If you do not agree to
these terms, you may not use our service. These
terms constitute a legally binding agreement
between you and Whisper Money.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
You must be at least 16 years old to use this service. By using Whisper Money, you
represent and warrant that you meet this age requirement.
You must be at least 16 years old to use this
service. By using Whisper Money, you represent
and warrant that you meet this age requirement.
</p>
</section>
@ -74,18 +89,33 @@ export default function Terms() {
3. Service Description
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
Whisper Money is a personal finance tracking application that allows you to:
Whisper Money is a personal finance tracking
application that allows you to:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li>Manually record and track your financial transactions, budgets, and expenses</li>
<li>Organize financial data with categories and custom labels</li>
<li>Sync your encrypted financial data across multiple devices via cloud storage</li>
<li>Access your data with end-to-end encryption to ensure privacy and security</li>
<li>
Manually record and track your financial
transactions, budgets, and expenses
</li>
<li>
Organize financial data with categories and
custom labels
</li>
<li>
Sync your encrypted financial data across
multiple devices via cloud storage
</li>
<li>
Access your data with end-to-end encryption
to ensure privacy and security
</li>
</ul>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We reserve the right to modify, suspend, or discontinue any part of the service at any
time, with or without notice. We will not be liable to you or any third party for any
modification, suspension, or discontinuation of the service.
We reserve the right to modify, suspend, or
discontinue any part of the service at any time,
with or without notice. We will not be liable to
you or any third party for any modification,
suspension, or discontinuation of the service.
</p>
</section>
@ -94,20 +124,41 @@ export default function Terms() {
4. User Accounts and Responsibilities
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
When you create an account with Whisper Money, you agree to:
When you create an account with Whisper Money,
you agree to:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li>Provide accurate, current, and complete information during registration</li>
<li>Maintain and promptly update your account information</li>
<li>Maintain the security and confidentiality of your account credentials</li>
<li>Accept responsibility for all activities that occur under your account</li>
<li>Notify us immediately of any unauthorized access or security breach</li>
<li>Use the service only for lawful purposes and in compliance with all applicable laws</li>
<li>
Provide accurate, current, and complete
information during registration
</li>
<li>
Maintain and promptly update your account
information
</li>
<li>
Maintain the security and confidentiality of
your account credentials
</li>
<li>
Accept responsibility for all activities
that occur under your account
</li>
<li>
Notify us immediately of any unauthorized
access or security breach
</li>
<li>
Use the service only for lawful purposes and
in compliance with all applicable laws
</li>
</ul>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
You may not use the service to engage in any illegal activity, transmit malicious code,
attempt to gain unauthorized access to our systems, or interfere with the proper functioning
of the service.
You may not use the service to engage in any
illegal activity, transmit malicious code,
attempt to gain unauthorized access to our
systems, or interfere with the proper
functioning of the service.
</p>
</section>
@ -116,18 +167,24 @@ export default function Terms() {
5. Data Ownership and License
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
<strong>Your Data:</strong> You retain all ownership rights to the financial data and
information you enter into Whisper Money. We do not claim any ownership over your personal
<strong>Your Data:</strong> You retain all
ownership rights to the financial data and
information you enter into Whisper Money. We do
not claim any ownership over your personal
financial information.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
By using our service, you grant us a limited license to store, process, and transmit your
data solely for the purpose of providing the service to you. This license terminates when
you delete your data or close your account.
By using our service, you grant us a limited
license to store, process, and transmit your
data solely for the purpose of providing the
service to you. This license terminates when you
delete your data or close your account.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
You are responsible for maintaining backups of your data. While we implement reasonable
backup procedures, we recommend you export and save copies of important data regularly.
You are responsible for maintaining backups of
your data. While we implement reasonable backup
procedures, we recommend you export and save
copies of important data regularly.
</p>
</section>
@ -136,14 +193,19 @@ export default function Terms() {
6. Intellectual Property Rights
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
The Whisper Money platform, including its software, design, text, graphics, logos, and
other content (excluding your personal data), is owned by Whisper Money and protected by
copyright, trademark, and other intellectual property laws. You may not copy, modify,
distribute, sell, or lease any part of our service or software without our explicit written
The Whisper Money platform, including its
software, design, text, graphics, logos, and
other content (excluding your personal data), is
owned by Whisper Money and protected by
copyright, trademark, and other intellectual
property laws. You may not copy, modify,
distribute, sell, or lease any part of our
service or software without our explicit written
permission.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
The Whisper Money name and logo are trademarks of Whisper Money. You may not use these
The Whisper Money name and logo are trademarks
of Whisper Money. You may not use these
trademarks without our prior written consent.
</p>
</section>
@ -153,19 +215,42 @@ export default function Terms() {
7. Payment Terms
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
If you subscribe to a paid plan or purchase premium features:
If you subscribe to a paid plan or purchase
premium features:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li>You agree to pay all applicable fees as described at the time of purchase</li>
<li>Payments are processed by third-party payment processors and subject to their terms</li>
<li>Subscription fees are billed in advance on a recurring basis until cancelled</li>
<li>All fees are non-refundable except as required by law or explicitly stated otherwise</li>
<li>We reserve the right to change our pricing with 30 days advance notice to subscribers</li>
<li>You are responsible for all taxes associated with your purchase</li>
<li>
You agree to pay all applicable fees as
described at the time of purchase
</li>
<li>
Payments are processed by third-party
payment processors and subject to their
terms
</li>
<li>
Subscription fees are billed in advance on a
recurring basis until cancelled
</li>
<li>
All fees are non-refundable except as
required by law or explicitly stated
otherwise
</li>
<li>
We reserve the right to change our pricing
with 30 days advance notice to subscribers
</li>
<li>
You are responsible for all taxes associated
with your purchase
</li>
</ul>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
You may cancel your subscription at any time. Upon cancellation, you will retain access
until the end of your current billing period, after which your subscription will not renew.
You may cancel your subscription at any time.
Upon cancellation, you will retain access until
the end of your current billing period, after
which your subscription will not renew.
</p>
</section>
@ -174,19 +259,25 @@ export default function Terms() {
8. Termination
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
You may terminate your account at any time by deleting your account through the application
settings or by contacting us. Upon termination, your right to use the service will
immediately cease.
You may terminate your account at any time by
deleting your account through the application
settings or by contacting us. Upon termination,
your right to use the service will immediately
cease.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We may suspend or terminate your account if you violate these Terms, engage in fraudulent
activity, or for any other reason at our discretion. We will provide notice when reasonably
possible, but we reserve the right to immediately terminate accounts in cases of serious
violations or legal requirements.
We may suspend or terminate your account if you
violate these Terms, engage in fraudulent
activity, or for any other reason at our
discretion. We will provide notice when
reasonably possible, but we reserve the right to
immediately terminate accounts in cases of
serious violations or legal requirements.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
Upon termination, we will delete your personal data in accordance with our Privacy Policy
and applicable law, typically within 30 days.
Upon termination, we will delete your personal
data in accordance with our Privacy Policy and
applicable law, typically within 30 days.
</p>
</section>
@ -195,21 +286,32 @@ export default function Terms() {
9. Disclaimers and Warranties
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
<strong>Financial Advice Disclaimer:</strong> Whisper Money is a tool for tracking and
organizing your financial information. It does not provide financial, investment, tax, or
legal advice. You should consult with qualified professionals for such advice. We are not
responsible for any financial decisions you make based on data in the application.
<strong>Financial Advice Disclaimer:</strong>{' '}
Whisper Money is a tool for tracking and
organizing your financial information. It does
not provide financial, investment, tax, or legal
advice. You should consult with qualified
professionals for such advice. We are not
responsible for any financial decisions you make
based on data in the application.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
<strong>Service Availability:</strong> The service is provided "as is" and "as available"
without warranties of any kind. We do not guarantee that the service will be uninterrupted,
error-free, or completely secure. We strive to maintain high availability but do not
warrant that the service will meet your specific requirements.
<strong>Service Availability:</strong> The
service is provided "as is" and "as available"
without warranties of any kind. We do not
guarantee that the service will be
uninterrupted, error-free, or completely secure.
We strive to maintain high availability but do
not warrant that the service will meet your
specific requirements.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
<strong>Data Accuracy:</strong> You are responsible for the accuracy of the data you enter.
We do not verify or guarantee the accuracy of your financial information. Any calculations
or reports generated by the service are based on the data you provide.
<strong>Data Accuracy:</strong> You are
responsible for the accuracy of the data you
enter. We do not verify or guarantee the
accuracy of your financial information. Any
calculations or reports generated by the service
are based on the data you provide.
</p>
</section>
@ -218,27 +320,51 @@ export default function Terms() {
10. Limitation of Liability
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
To the maximum extent permitted by applicable law, Whisper Money, its directors, employees,
and affiliates shall not be liable for any indirect, incidental, special, consequential, or
punitive damages, including without limitation: loss of profits, data, use, goodwill, or
other intangible losses resulting from:
To the maximum extent permitted by applicable
law, Whisper Money, its directors, employees,
and affiliates shall not be liable for any
indirect, incidental, special, consequential, or
punitive damages, including without limitation:
loss of profits, data, use, goodwill, or other
intangible losses resulting from:
</p>
<ul className="mb-4 list-disc pl-6 text-[#706f6c] dark:text-[#A1A09A]">
<li>Your use or inability to use the service</li>
<li>Any unauthorized access to or use of our servers and/or personal information</li>
<li>Any interruption or cessation of transmission to or from the service</li>
<li>Any bugs, viruses, or similar harmful components transmitted through the service</li>
<li>Any errors or omissions in content or any loss or damage incurred from using content</li>
<li>Financial decisions made based on data in the application</li>
<li>
Your use or inability to use the service
</li>
<li>
Any unauthorized access to or use of our
servers and/or personal information
</li>
<li>
Any interruption or cessation of
transmission to or from the service
</li>
<li>
Any bugs, viruses, or similar harmful
components transmitted through the service
</li>
<li>
Any errors or omissions in content or any
loss or damage incurred from using content
</li>
<li>
Financial decisions made based on data in
the application
</li>
</ul>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
Our total liability to you for all claims arising from or related to the service shall not
exceed the amount you paid us in the 12 months prior to the claim, or 100, whichever is
Our total liability to you for all claims
arising from or related to the service shall not
exceed the amount you paid us in the 12 months
prior to the claim, or 100, whichever is
greater.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
Nothing in these Terms excludes or limits our liability for death or personal injury caused
by negligence, fraud, or any liability that cannot be excluded or limited under applicable law.
Nothing in these Terms excludes or limits our
liability for death or personal injury caused by
negligence, fraud, or any liability that cannot
be excluded or limited under applicable law.
</p>
</section>
@ -247,10 +373,14 @@ export default function Terms() {
11. Indemnification
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
You agree to indemnify, defend, and hold harmless Whisper Money and its officers, directors,
employees, and agents from any claims, liabilities, damages, losses, and expenses, including
reasonable legal fees, arising out of or related to your use of the service, violation of
these Terms, or violation of any rights of another party.
You agree to indemnify, defend, and hold
harmless Whisper Money and its officers,
directors, employees, and agents from any
claims, liabilities, damages, losses, and
expenses, including reasonable legal fees,
arising out of or related to your use of the
service, violation of these Terms, or violation
of any rights of another party.
</p>
</section>
@ -259,17 +389,23 @@ export default function Terms() {
12. Governing Law and Dispute Resolution
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
These Terms shall be governed by and construed in accordance with the laws of Spain,
without regard to its conflict of law provisions. You agree to submit to the exclusive
jurisdiction of the courts located in Madrid, Spain for the resolution of any disputes.
These Terms shall be governed by and construed
in accordance with the laws of Spain, without
regard to its conflict of law provisions. You
agree to submit to the exclusive jurisdiction of
the courts located in Madrid, Spain for the
resolution of any disputes.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
For users in the European Union, nothing in these Terms affects your rights as a consumer
under applicable consumer protection laws, including your right to bring proceedings in
For users in the European Union, nothing in
these Terms affects your rights as a consumer
under applicable consumer protection laws,
including your right to bring proceedings in
your country of residence.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We encourage you to contact us directly at victor@whisper.money to resolve any disputes
We encourage you to contact us directly at
victor@whisper.money to resolve any disputes
before initiating legal proceedings.
</p>
</section>
@ -279,14 +415,19 @@ export default function Terms() {
13. Changes to Terms
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
We reserve the right to modify or replace these Terms at any time. If a revision is
material, we will provide at least 30 days notice prior to the new terms taking effect by
sending you an email notification and/or posting a notice on our website.
We reserve the right to modify or replace these
Terms at any time. If a revision is material, we
will provide at least 30 days notice prior to
the new terms taking effect by sending you an
email notification and/or posting a notice on
our website.
</p>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
Your continued use of the service after the effective date of revised Terms constitutes
your acceptance of the changes. If you do not agree to the new terms, you must stop using
the service and may delete your account.
Your continued use of the service after the
effective date of revised Terms constitutes your
acceptance of the changes. If you do not agree
to the new terms, you must stop using the
service and may delete your account.
</p>
</section>
@ -295,9 +436,12 @@ export default function Terms() {
14. Severability
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
If any provision of these Terms is found to be invalid, illegal, or unenforceable, the
remaining provisions shall continue in full force and effect. The invalid provision shall
be modified to the minimum extent necessary to make it valid and enforceable.
If any provision of these Terms is found to be
invalid, illegal, or unenforceable, the
remaining provisions shall continue in full
force and effect. The invalid provision shall be
modified to the minimum extent necessary to make
it valid and enforceable.
</p>
</section>
@ -306,8 +450,10 @@ export default function Terms() {
15. Entire Agreement
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
These Terms, together with our Privacy Policy, constitute the entire agreement between you
and Whisper Money regarding the use of our service and supersede any prior agreements or
These Terms, together with our Privacy Policy,
constitute the entire agreement between you and
Whisper Money regarding the use of our service
and supersede any prior agreements or
understandings, whether written or oral.
</p>
</section>
@ -317,12 +463,17 @@ export default function Terms() {
16. Contact Us
</h2>
<p className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
If you have any questions, concerns, or feedback about these Terms of Service, please
contact us:
If you have any questions, concerns, or feedback
about these Terms of Service, please contact us:
</p>
<div className="mb-4 text-[#706f6c] dark:text-[#A1A09A]">
<p><strong>Email:</strong> victor@whisper.money</p>
<p><strong>Address:</strong> Whisper Money, Calle Oca, Madrid - 28025, Spain</p>
<p>
<strong>Email:</strong> victor@whisper.money
</p>
<p>
<strong>Address:</strong> Whisper Money,
Calle Oca, Madrid - 28025, Spain
</p>
</div>
</section>
</div>
@ -331,4 +482,3 @@ export default function Terms() {
</>
);
}

View File

@ -103,20 +103,24 @@ function TransactionRowComponent({
return (
<ContextMenu key={row.id} onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
{ }
{}
<TableRow
ref={rowVirtualizer.measureElement}
data-state={(row.getIsSelected() || contextMenuOpen) && 'selected'}
data-state={
(row.getIsSelected() || contextMenuOpen) && 'selected'
}
data-index={virtualRow.index}
>
{row.getVisibleCells().map((cell: Cell<DecryptedTransaction, unknown>) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
{row
.getVisibleCells()
.map((cell: Cell<DecryptedTransaction, unknown>) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
@ -1012,7 +1016,11 @@ export default function Transactions({ categories, accounts, banks }: Props) {
}
const renderTransactionRow = useCallback(
(row: Row<DecryptedTransaction>, virtualRow: VirtualItem, rowVirtualizer: Virtualizer<HTMLDivElement, Element>) => {
(
row: Row<DecryptedTransaction>,
virtualRow: VirtualItem,
rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
) => {
return (
<TransactionRowComponent
key={row.id}

View File

@ -1,29 +1,27 @@
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { LEAD_FUNNEL_EVENT_UUID } from '@/lib/constants';
import { trackEvent } from '@/lib/track-event';
import { dashboard } from '@/routes';
import { store } from '@/routes/user-leads';
import { type SharedData } from '@/types';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import InputError from '@/components/input-error';
import { Separator } from '@radix-ui/react-separator';
import {
LockIcon,
ShieldCheckIcon,
TrendingUpIcon,
BellIcon,
PieChartIcon,
SmartphoneIcon,
ZapIcon,
EyeOffIcon,
BirdIcon,
CodeIcon,
EyeOffIcon,
Github,
GithubIcon,
LucideGithub,
LockIcon,
PieChartIcon,
ShieldCheckIcon,
SmartphoneIcon,
TrendingUpIcon,
ZapIcon,
} from 'lucide-react';
import { useEffect, useRef } from 'react';
import { trackEvent } from '@/lib/track-event';
import { LEAD_FUNNEL_EVENT_UUID } from '@/lib/constants';
import { Separator } from '@radix-ui/react-separator';
export default function Welcome({
canRegister = true,
@ -60,38 +58,64 @@ export default function Welcome({
<link rel="canonical" href={appUrl} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Stack+Sans+Text:wght@200..700&display=swap" rel="stylesheet" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Stack+Sans+Text:wght@200..700&display=swap"
rel="stylesheet"
/>
<meta property="og:site_name" content="Whisper Money" />
<meta property="og:title" content="Whisper Money - The Most Secure Personal Finance App" />
<meta
property="og:title"
content="Whisper Money - The Most Secure Personal Finance App"
/>
<meta
property="og:description"
content="Your financial data stays private with end-to-end encryption. The most secure way to manage your personal finances."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content={appUrl} />
<meta property="og:image" content={`${appUrl}/images/og_whisper_money.png`} />
<meta
property="og:image"
content={`${appUrl}/images/og_whisper_money.png`}
/>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Whisper Money - Secure Personal Finance App" />
<meta
property="og:image:alt"
content="Whisper Money - Secure Personal Finance App"
/>
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Whisper Money - The Most Secure Personal Finance App" />
<meta
name="twitter:title"
content="Whisper Money - The Most Secure Personal Finance App"
/>
<meta
name="twitter:description"
content="Your financial data stays private with end-to-end encryption. The most secure way to manage your personal finances."
/>
<meta name="twitter:image" content={`${appUrl}/images/og_whisper_money.png`} />
<meta name="twitter:image:alt" content="Whisper Money - Secure Personal Finance App" />
<meta
name="twitter:image"
content={`${appUrl}/images/og_whisper_money.png`}
/>
<meta
name="twitter:image:alt"
content="Whisper Money - Secure Personal Finance App"
/>
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'Whisper Money',
description: 'The most secure personal finance app with end-to-end encryption. Track expenses, create budgets, and manage your money privately.',
description:
'The most secure personal finance app with end-to-end encryption. Track expenses, create budgets, and manage your money privately.',
url: appUrl,
applicationCategory: 'FinanceApplication',
offers: {
@ -111,11 +135,11 @@ export default function Welcome({
</script>
</Head>
<div className="flex min-h-screen flex-col bg-[#FDFDFC] text-[#1b1b18] dark:bg-[#0a0a0a] dark:text-[#EDEDEC]">
<header className="w-full fade-bottom bg-background/5 fixed top-0 z-50 backdrop-blur-lg">
<header className="fade-bottom fixed top-0 z-50 w-full bg-background/5 backdrop-blur-lg">
<div className="mx-auto flex max-w-5xl items-center justify-between px-2 py-4 lg:py-6">
<div className="flex items-center gap-4 font-mono">
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className='font-medium'>Whisper Money</span>
<span className="font-medium">Whisper Money</span>
</div>
<nav className="flex items-center gap-4">
<a
@ -123,38 +147,42 @@ export default function Welcome({
target="_blank"
rel="noopener noreferrer"
>
<Button variant={'ghost'} className='cursor-pointer opacity-70 hover:opacity-100 transition-all duration-200'>
<Button
variant={'ghost'}
className="cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100"
>
<Github className="size-5" />
Github
</Button>
</a>
{!hideAuthButtons && (
<>
<Separator orientation="vertical"
className="data-[orientation=vertical]:bg-border data-[orientation=vertical]:w-[1px] data-[orientation=vertical]:h-6"
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-6 data-[orientation=vertical]:w-[1px] data-[orientation=vertical]:bg-border"
/>
{auth.user ? (
<Link
href={dashboard()}
>
<Button className='cursor-pointer'>
<Link href={dashboard()}>
<Button className="cursor-pointer">
Dashboard
</Button>
</Link>
) : (
<>
<Link
href="/login"
>
<Button variant={'ghost'} className='cursor-pointer'>
<Link href="/login">
<Button
variant={'ghost'}
className="cursor-pointer"
>
Log in
</Button>
</Link>
{canRegister && (
<Link
href="/register"
>
<Button variant='default' className='cursor-pointer'>
<Link href="/register">
<Button
variant="default"
className="cursor-pointer"
>
Register
</Button>
</Link>
@ -170,17 +198,18 @@ export default function Welcome({
<main className="flex flex-1 flex-col">
<section className="relative w-full overflow-hidden px-6 py-24 sm:py-32 md:py-40">
<div className="relative z-10 mx-auto flex max-w-7xl flex-col gap-12">
<div className="max-w-5xl w-full mx-auto relative z-10 flex flex-col items-start gap-2 sm:gap-8">
<span className="inline-flex items-center gap-2 rounded-full border border-[#e3e3e0] py-1 px-2.5 text-[0.8rem] font-medium dark:border-[#3E3E3A]">
<LockIcon className='size-3.5 opacity-75' />
<div className="relative z-10 mx-auto flex w-full max-w-5xl flex-col items-start gap-2 sm:gap-8">
<span className="inline-flex items-center gap-2 rounded-full border border-[#e3e3e0] px-2.5 py-1 text-[0.8rem] font-medium dark:border-[#3E3E3A]">
<LockIcon className="size-3.5 opacity-75" />
<span className="text-[#706f6c] dark:text-[#A1A09A]">
Military Grade Encryption
</span>
</span>
<h1 className="max-w-[840px] bg-gradient-to-r from-[#1b1b18] to-[#1b1b18] bg-clip-text text-4xl font-semibold leading-tight text-transparent drop-shadow-2xl dark:from-[#EDEDEC] dark:to-[#A1A09A] sm:text-5xl sm:leading-tight lg:text-6xl lg:leading-tight font-heading">
The most secure way to understand your finances
<h1 className="font-heading max-w-[840px] bg-gradient-to-r from-[#1b1b18] to-[#1b1b18] bg-clip-text text-4xl leading-tight font-semibold text-transparent drop-shadow-2xl sm:text-5xl sm:leading-tight lg:text-6xl lg:leading-tight dark:from-[#EDEDEC] dark:to-[#A1A09A]">
The most secure way to understand your
finances
</h1>
<p className="max-w-[840px] mb-4 text-lg leading-8 font-medium text-[#706f6c] dark:text-[#A1A09A] lg:text-xl lg:leading-8">
<p className="mb-4 max-w-[840px] text-lg leading-8 font-medium text-[#706f6c] lg:text-xl lg:leading-8 dark:text-[#A1A09A]">
Your financial data stays private with
end-to-end encryption. Track expenses,
create budgets, and achieve your goalsall
@ -190,7 +219,7 @@ export default function Welcome({
<div className="flex w-full max-w-lg flex-col gap-4">
<Form
{...store.form()}
className="flex sm:items-center justify-center flex-col gap-2 sm:flex-row"
className="flex flex-col justify-center gap-2 sm:flex-row sm:items-center"
>
{({ processing, errors }) => (
<>
@ -206,7 +235,7 @@ export default function Welcome({
<Button
type="submit"
disabled={processing}
className="h-12 bg-gradient-to-t px-6 from-zinc-700 to-zinc-900 dark:from-zinc-200 dark:to-zinc-300 text-shadow cursor-pointer disabled:cursor-default text-white shadow-sm transition-all duration hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:text-[#1C1C1A] dark:hover:bg-white dark:hover:shadow-md hover:dark:from-zinc-50"
className="text-shadow duration h-12 cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 px-6 text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md disabled:cursor-default dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
>
{processing
? 'Submitting...'
@ -236,8 +265,16 @@ export default function Welcome({
<div className="relative z-10 overflow-hidden rounded-2xl border border-[#e3e3e0]/50 bg-[#FDFDFC]/50 p-2 shadow-2xl dark:border-[#3E3E3A]/10 dark:bg-[#161615]/50">
<div className="relative z-10 overflow-hidden rounded-md border border-[#e3e3e0]/70 shadow-2xl dark:border-[#3E3E3A]/5">
<div className="aspect-[16/10] rounded-lg border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#0a0a0a]">
<img src="/images/landing/bank-accounts.png" alt="Bank accounts securely stored" className="dark:hidden h-full w-auto rounded-lg" />
<img src="/images/landing/dark_bank-accounts.png" alt="Bank accounts securely stored" className="hidden dark:block h-full w-auto rounded-lg" />
<img
src="/images/landing/bank-accounts.png"
alt="Bank accounts securely stored"
className="h-full w-auto rounded-lg dark:hidden"
/>
<img
src="/images/landing/dark_bank-accounts.png"
alt="Bank accounts securely stored"
className="hidden h-full w-auto rounded-lg dark:block"
/>
</div>
</div>
</div>
@ -246,8 +283,16 @@ export default function Welcome({
<div className="relative z-10 overflow-hidden rounded-2xl border border-[#e3e3e0]/50 bg-[#FDFDFC]/50 p-2 shadow-2xl dark:border-[#3E3E3A]/10 dark:bg-[#161615]/50">
<div className="relative z-10 overflow-hidden rounded-md border border-[#e3e3e0]/70 shadow-2xl dark:border-[#3E3E3A]/5">
<div className="aspect-[16/10] rounded-lg border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#0a0a0a]">
<img src="/images/landing/unlock-key.png" alt="Everything is encrypted with a private & local key you only have" className="dark:hidden h-full w-auto rounded-lg" />
<img src="/images/landing/dark_unlock-key.png" alt="Everything is encrypted with a private & local key you only have" className="hidden dark:block h-full w-auto rounded-lg" />
<img
src="/images/landing/unlock-key.png"
alt="Everything is encrypted with a private & local key you only have"
className="h-full w-auto rounded-lg dark:hidden"
/>
<img
src="/images/landing/dark_unlock-key.png"
alt="Everything is encrypted with a private & local key you only have"
className="hidden h-full w-auto rounded-lg dark:block"
/>
</div>
</div>
</div>
@ -256,8 +301,16 @@ export default function Welcome({
<div className="relative z-10 overflow-hidden rounded-2xl border border-[#e3e3e0]/50 bg-[#FDFDFC]/50 p-2 shadow-2xl dark:border-[#3E3E3A]/10 dark:bg-[#161615]/50">
<div className="relative z-10 overflow-hidden rounded-md border border-[#e3e3e0]/70 shadow-2xl dark:border-[#3E3E3A]/5">
<div className="aspect-[16/10] rounded-lg border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#0a0a0a]">
<img src="/images/landing/transactions.png" alt="You're transactions with a military grade encryption" className="dark:hidden h-full w-auto rounded-lg" />
<img src="/images/landing/dark_transactions.png" alt="You're transactions with a military grade encryption" className="hidden dark:block h-full w-auto rounded-lg" />
<img
src="/images/landing/transactions.png"
alt="You're transactions with a military grade encryption"
className="h-full w-auto rounded-lg dark:hidden"
/>
<img
src="/images/landing/dark_transactions.png"
alt="You're transactions with a military grade encryption"
className="hidden h-full w-auto rounded-lg dark:block"
/>
</div>
</div>
</div>
@ -265,7 +318,7 @@ export default function Welcome({
<div
data-slot="glow"
className="absolute top-[50%] mt-32 w-full animate-appear-zoom opacity-0 delay-2000 lg:mt-4"
className="animate-appear-zoom absolute top-[50%] mt-32 w-full opacity-0 delay-2000 lg:mt-4"
>
<div className="absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 -translate-y-1/2 scale-[2.5] rounded-[50%] bg-radial from-[#1b1b18]/50 from-10% to-[#1b1b18]/0 to-60% opacity-20 sm:h-[512px] dark:from-[#EDEDEC]/50 dark:to-[#EDEDEC]/0 dark:opacity-100"></div>
<div className="absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 -translate-y-1/2 scale-200 rounded-[50%] bg-radial from-[#1b1b18]/30 from-10% to-[#1b1b18]/0 to-60% opacity-20 sm:h-[256px] dark:from-[#EDEDEC]/30 dark:to-[#EDEDEC]/0 dark:opacity-100"></div>
@ -274,131 +327,131 @@ export default function Welcome({
</div>
</section>
<section className="px-4 py-12 dark:border-[#3E3E3A] sm:py-24 md:py-32">
<section className="px-4 py-12 sm:py-24 md:py-32 dark:border-[#3E3E3A]">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-6 sm:gap-20">
<h2 className="max-w-[560px] text-center text-3xl font-semibold leading-tight sm:text-5xl sm:leading-tight">
<h2 className="max-w-[560px] text-center text-3xl leading-tight font-semibold sm:text-5xl sm:leading-tight">
Everything you need. Nothing you don't.
</h2>
<div className="grid auto-rows-fr grid-cols-2 gap-0 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4">
<div className="flex flex-col gap-4 p-4 text-[#1b1b18] dark:text-[#EDEDEC]">
<h3 className="flex items-center gap-2 text-sm font-semibold leading-none tracking-tight sm:text-base">
<h3 className="flex items-center gap-2 text-sm leading-none font-semibold tracking-tight sm:text-base">
<div className="flex items-center self-start">
<ShieldCheckIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
End-to-end encryption
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-balance text-sm text-[#706f6c] dark:text-[#A1A09A]">
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
Your financial data is encrypted on your
device. Only you can access it.
</div>
</div>
<div className="flex flex-col gap-4 p-4 text-[#1b1b18] dark:text-[#EDEDEC]">
<h3 className="flex items-center gap-2 text-sm font-semibold leading-none tracking-tight sm:text-base">
<h3 className="flex items-center gap-2 text-sm leading-none font-semibold tracking-tight sm:text-base">
<div className="flex items-center self-start">
<TrendingUpIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
Smart budgeting
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-balance text-sm text-[#706f6c] dark:text-[#A1A09A]">
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
Create budgets that adapt to your
spending habits and goals.
</div>
</div>
<div className="flex flex-col gap-4 p-4 text-[#1b1b18] dark:text-[#EDEDEC]">
<h3 className="flex items-center gap-2 text-sm font-semibold leading-none tracking-tight sm:text-base">
<h3 className="flex items-center gap-2 text-sm leading-none font-semibold tracking-tight sm:text-base">
<div className="flex items-center self-start">
<BellIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
Intelligent insights
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-balance text-sm text-[#706f6c] dark:text-[#A1A09A]">
Are you overspending?
Know exactly where you stand.
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
Are you overspending? Know exactly where
you stand.
</div>
</div>
<div className="flex flex-col gap-4 p-4 text-[#1b1b18] dark:text-[#EDEDEC]">
<h3 className="flex items-center gap-2 text-sm font-semibold leading-none tracking-tight sm:text-base">
<h3 className="flex items-center gap-2 text-sm leading-none font-semibold tracking-tight sm:text-base">
<div className="flex items-center self-start">
<PieChartIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
Visual insights
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-balance text-sm text-[#706f6c] dark:text-[#A1A09A]">
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
Understand your spending with beautiful
charts and reports.
</div>
</div>
<div className="flex flex-col gap-4 p-4 text-[#1b1b18] dark:text-[#EDEDEC]">
<h3 className="flex items-center gap-2 text-sm font-semibold leading-none tracking-tight sm:text-base">
<h3 className="flex items-center gap-2 text-sm leading-none font-semibold tracking-tight sm:text-base">
<div className="flex items-center self-start">
<SmartphoneIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
Works everywhere
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-balance text-sm text-[#706f6c] dark:text-[#A1A09A]">
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
Access your finances on any device,
anytime, anywhere.
</div>
</div>
<div className="flex flex-col gap-4 p-4 text-[#1b1b18] dark:text-[#EDEDEC]">
<h3 className="flex items-center gap-2 text-sm font-semibold leading-none tracking-tight sm:text-base">
<h3 className="flex items-center gap-2 text-sm leading-none font-semibold tracking-tight sm:text-base">
<div className="flex items-center self-start">
<ZapIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
Lightning fast
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-balance text-sm text-[#706f6c] dark:text-[#A1A09A]">
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
Built for speed with instant sync and
smooth interactions.
</div>
</div>
<div className="flex flex-col gap-4 p-4 text-[#1b1b18] dark:text-[#EDEDEC]">
<h3 className="flex items-center gap-2 text-sm font-semibold leading-none tracking-tight sm:text-base">
<h3 className="flex items-center gap-2 text-sm leading-none font-semibold tracking-tight sm:text-base">
<div className="flex items-center self-start">
<EyeOffIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
Zero tracking
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-balance text-sm text-[#706f6c] dark:text-[#A1A09A]">
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
We don't track, sell, or share your
data. Ever.
</div>
</div>
<div className="flex flex-col gap-4 p-4 text-[#1b1b18] dark:text-[#EDEDEC]">
<h3 className="flex items-center gap-2 text-sm font-semibold leading-none tracking-tight sm:text-base">
<h3 className="flex items-center gap-2 text-sm leading-none font-semibold tracking-tight sm:text-base">
<div className="flex items-center self-start">
<CodeIcon className="size-5 stroke-1 text-[#1b1b18] dark:text-[#EDEDEC]" />
</div>
Open source
</h3>
<div className="flex max-w-[240px] flex-col gap-2 text-balance text-sm text-[#706f6c] dark:text-[#A1A09A]">
Fully transparent and open source. Review
the code yourself.
<div className="flex max-w-[240px] flex-col gap-2 text-sm text-balance text-[#706f6c] dark:text-[#A1A09A]">
Fully transparent and open source.
Review the code yourself.
</div>
</div>
</div>
</div>
</section>
<section className="w-full overflow-hidden px-0 py-12 dark:border-[#3E3E3A] sm:py-24 md:py-32">
<section className="w-full overflow-hidden px-0 py-12 sm:py-24 md:py-32 dark:border-[#3E3E3A]">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-4 px-1 text-center sm:gap-16">
<div className="flex flex-col items-center gap-4 px-4 sm:gap-4">
<h2 className="max-w-[720px] text-2xl font-semibold leading-tight sm:text-3xl sm:leading-tight">
<h2 className="max-w-[720px] text-2xl leading-tight font-semibold sm:text-3xl sm:leading-tight">
Trusted by people who value their privacy
</h2>
<p className="text-md max-w-[600px] font-medium text-[#706f6c] dark:text-[#A1A09A] sm:text-xl">
<p className="text-md max-w-[600px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
Join thousands of users who have taken
control of their finances without
compromising their privacy.
</p>
</div>
<div className="relative flex w-full flex-col items-center justify-center overflow-hidden">
<div className="group flex flex-row overflow-hidden p-2 [--duration:20s] [--gap:1rem] [gap:var(--gap)]">
<div className="flex shrink-0 animate-marquee flex-row justify-around group-hover:[animation-play-state:paused] [gap:var(--gap)]">
<div className="group flex flex-row [gap:var(--gap)] overflow-hidden p-2 [--duration:20s] [--gap:1rem]">
<div className="animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)] group-hover:[animation-play-state:paused]">
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Sarah M.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -416,7 +469,7 @@ export default function Welcome({
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Michael R.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -434,7 +487,7 @@ export default function Welcome({
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Emma L.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -444,16 +497,16 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
Love that my financial data is
encrypted. No more worrying about
data breaches!
encrypted. No more worrying
about data breaches!
</p>
</div>
</div>
<div className="flex shrink-0 animate-marquee flex-row justify-around group-hover:[animation-play-state:paused] [gap:var(--gap)]">
<div className="animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)] group-hover:[animation-play-state:paused]">
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Sarah M.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -471,7 +524,7 @@ export default function Welcome({
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Michael R.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -489,7 +542,7 @@ export default function Welcome({
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Emma L.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -499,16 +552,16 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
Love that my financial data is
encrypted. No more worrying about
data breaches!
encrypted. No more worrying
about data breaches!
</p>
</div>
</div>
<div className="flex shrink-0 animate-marquee flex-row justify-around group-hover:[animation-play-state:paused] [gap:var(--gap)]">
<div className="animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)] group-hover:[animation-play-state:paused]">
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Sarah M.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -526,7 +579,7 @@ export default function Welcome({
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Michael R.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -544,7 +597,7 @@ export default function Welcome({
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Emma L.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -554,16 +607,16 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
Love that my financial data is
encrypted. No more worrying about
data breaches!
encrypted. No more worrying
about data breaches!
</p>
</div>
</div>
<div className="flex shrink-0 animate-marquee flex-row justify-around group-hover:[animation-play-state:paused] [gap:var(--gap)]">
<div className="animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)] group-hover:[animation-play-state:paused]">
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Sarah M.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -581,7 +634,7 @@ export default function Welcome({
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Michael R.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -599,7 +652,7 @@ export default function Welcome({
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Emma L.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -609,18 +662,18 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
Love that my financial data is
encrypted. No more worrying about
data breaches!
encrypted. No more worrying
about data breaches!
</p>
</div>
</div>
</div>
<div className="group flex flex-row overflow-hidden p-2 [--duration:20s] [--gap:1rem] [gap:var(--gap)]">
<div className="flex shrink-0 animate-marquee flex-row justify-around group-hover:[animation-play-state:paused] [animation-direction:reverse] [gap:var(--gap)]">
<div className="group flex flex-row [gap:var(--gap)] overflow-hidden p-2 [--duration:20s] [--gap:1rem]">
<div className="animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)] [animation-direction:reverse] group-hover:[animation-play-state:paused]">
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
David K.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -631,13 +684,14 @@ export default function Welcome({
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
As a developer, I appreciate the
security architecture. This is
how finance apps should be built.
how finance apps should be
built.
</p>
</div>
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Jessica P.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -647,14 +701,14 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
The automation rules save me so
much time. And knowing my data is
private? Priceless.
much time. And knowing my data
is private? Priceless.
</p>
</div>
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Alex T.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -670,11 +724,11 @@ export default function Welcome({
</p>
</div>
</div>
<div className="flex shrink-0 animate-marquee flex-row justify-around group-hover:[animation-play-state:paused] [animation-direction:reverse] [gap:var(--gap)]">
<div className="animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)] [animation-direction:reverse] group-hover:[animation-play-state:paused]">
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
David K.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -685,13 +739,14 @@ export default function Welcome({
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
As a developer, I appreciate the
security architecture. This is
how finance apps should be built.
how finance apps should be
built.
</p>
</div>
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Jessica P.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -701,14 +756,14 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
The automation rules save me so
much time. And knowing my data is
private? Priceless.
much time. And knowing my data
is private? Priceless.
</p>
</div>
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Alex T.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -724,11 +779,11 @@ export default function Welcome({
</p>
</div>
</div>
<div className="flex shrink-0 animate-marquee flex-row justify-around group-hover:[animation-play-state:paused] [animation-direction:reverse] [gap:var(--gap)]">
<div className="animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)] [animation-direction:reverse] group-hover:[animation-play-state:paused]">
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
David K.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -739,13 +794,14 @@ export default function Welcome({
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
As a developer, I appreciate the
security architecture. This is
how finance apps should be built.
how finance apps should be
built.
</p>
</div>
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Jessica P.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -755,14 +811,14 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
The automation rules save me so
much time. And knowing my data is
private? Priceless.
much time. And knowing my data
is private? Priceless.
</p>
</div>
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Alex T.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -778,11 +834,11 @@ export default function Welcome({
</p>
</div>
</div>
<div className="flex shrink-0 animate-marquee flex-row justify-around group-hover:[animation-play-state:paused] [animation-direction:reverse] [gap:var(--gap)]">
<div className="animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)] [animation-direction:reverse] group-hover:[animation-play-state:paused]">
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
David K.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -793,13 +849,14 @@ export default function Welcome({
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
As a developer, I appreciate the
security architecture. This is
how finance apps should be built.
how finance apps should be
built.
</p>
</div>
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Jessica P.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -809,14 +866,14 @@ export default function Welcome({
</div>
<p className="sm:text-md mt-4 text-sm text-[#706f6c] dark:text-[#A1A09A]">
The automation rules save me so
much time. And knowing my data is
private? Priceless.
much time. And knowing my data
is private? Priceless.
</p>
</div>
<div className="glass-3 flex max-w-[320px] flex-col rounded-lg p-4 text-start shadow-md sm:max-w-[420px] sm:p-6">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start">
<h3 className="text-md font-semibold leading-none">
<h3 className="text-md leading-none font-semibold">
Alex T.
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
@ -833,15 +890,15 @@ export default function Welcome({
</div>
</div>
</div>
<div className="from-background pointer-events-none absolute inset-y-0 left-0 hidden w-1/3 bg-linear-to-r sm:block"></div>
<div className="from-background pointer-events-none absolute inset-y-0 right-0 hidden w-1/3 bg-linear-to-l sm:block"></div>
<div className="pointer-events-none absolute inset-y-0 left-0 hidden w-1/3 bg-linear-to-r from-background sm:block"></div>
<div className="pointer-events-none absolute inset-y-0 right-0 hidden w-1/3 bg-linear-to-l from-background sm:block"></div>
</div>
</div>
</section>
</main>
<footer className="py-8 dark:border-[#3E3E3A]">
<div className="mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 text-sm text-[#706f6c] dark:text-[#A1A09A] sm:flex-row lg:px-8">
<div className="mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 text-sm text-[#706f6c] sm:flex-row lg:px-8 dark:text-[#A1A09A]">
<p>
© {new Date().getFullYear()} Whisper Money. All
rights reserved.

View File

@ -1,7 +1,6 @@
import { db } from '@/lib/dexie-db';
import { SyncManager } from '@/lib/sync-manager';
import type { AccountBalance } from '@/types/account';
import { uuidv7 } from 'uuidv7';
class AccountBalanceSyncService {
private syncManager: SyncManager;
@ -71,7 +70,10 @@ class AccountBalanceSyncService {
throw new Error('Balance not found');
}
return await this.syncManager.update<AccountBalance>(id.toString(), data);
return await this.syncManager.update<AccountBalance>(
id.toString(),
data,
);
}
async updateOrCreate(
@ -120,4 +122,3 @@ class AccountBalanceSyncService {
}
export const accountBalanceSyncService = new AccountBalanceSyncService();

View File

@ -25,7 +25,9 @@ class AccountSyncService {
}
async create(data: Omit<Account, 'id'>): Promise<Account> {
return await this.syncManager.createLocal<Account>(data as Omit<Account, 'id' | 'created_at' | 'updated_at'>);
return await this.syncManager.createLocal<Account>(
data as Omit<Account, 'id' | 'created_at' | 'updated_at'>,
);
}
async update(id: UUID, data: Partial<Account>): Promise<void> {

View File

@ -25,7 +25,9 @@ class CategorySyncService {
}
async create(data: Omit<Category, 'id'>): Promise<Category> {
return await this.syncManager.createLocal<Category>(data as Omit<Category, 'id' | 'created_at' | 'updated_at'>);
return await this.syncManager.createLocal<Category>(
data as Omit<Category, 'id' | 'created_at' | 'updated_at'>,
);
}
async update(id: UUID, data: Partial<Category>): Promise<void> {

View File

@ -1,2 +1 @@
export type UUID = string;