diff --git a/.env.local.template b/.env.local.template new file mode 100644 index 0000000..910d4dd --- /dev/null +++ b/.env.local.template @@ -0,0 +1,6 @@ +# Supabase +NEXT_PUBLIC_SUPABASE_URL="YOUR_SUPABASE_PROJECT_URL" +NEXT_PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY" + +# Resend (Email Service) +RESEND_API_KEY="YOUR_RESEND_API_KEY" diff --git a/package.json b/package.json index 0ebfd75..b46ce58 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "supabase:types": "supabase gen types typescript > src/types/supabase.ts" }, "dependencies": { + "@react-email/components": "^1.0.8", + "@react-email/render": "^2.0.4", "@serwist/next": "^9.5.6", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.39.0", @@ -20,7 +22,9 @@ "lucide-react": "^0.575.0", "next": "16.1.6", "react": "19.2.3", + "react-colorful": "^5.6.1", "react-dom": "19.2.3", + "resend": "^6.9.2", "serwist": "^9.5.6", "tailwind-merge": "^2.2.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 170a375..c36388c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@react-email/components': + specifier: ^1.0.8 + version: 1.0.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-email/render': + specifier: ^2.0.4 + version: 2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@serwist/next': specifier: ^9.5.6 version: 9.5.6(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -32,9 +38,15 @@ importers: react: specifier: 19.2.3 version: 19.2.3 + react-colorful: + specifier: ^5.6.1 + version: 5.6.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + resend: + specifier: ^6.9.2 + version: 6.9.2(@react-email/render@2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) serwist: specifier: ^9.5.6 version: 9.5.6(browserslist@4.28.1)(typescript@5.9.3) @@ -450,9 +462,171 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@react-email/body@0.2.1': + resolution: {integrity: sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/button@0.2.1': + resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-block@0.2.1': + resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-inline@0.0.6': + resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/column@0.0.14': + resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/components@1.0.8': + resolution: {integrity: sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/container@0.0.16': + resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/font@0.0.10': + resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/head@0.0.13': + resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/heading@0.0.16': + resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/hr@0.0.12': + resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/html@0.0.12': + resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/img@0.0.12': + resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/link@0.0.13': + resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/markdown@0.0.18': + resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/preview@0.0.14': + resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/render@2.0.4': + resolution: {integrity: sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/row@0.0.13': + resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/section@0.0.17': + resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/tailwind@2.0.5': + resolution: {integrity: sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@react-email/body': 0.2.1 + '@react-email/button': 0.2.1 + '@react-email/code-block': 0.2.1 + '@react-email/code-inline': 0.0.6 + '@react-email/container': 0.0.16 + '@react-email/heading': 0.0.16 + '@react-email/hr': 0.0.12 + '@react-email/img': 0.0.12 + '@react-email/link': 0.0.13 + '@react-email/preview': 0.0.14 + '@react-email/text': 0.1.6 + react: ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@react-email/body': + optional: true + '@react-email/button': + optional: true + '@react-email/code-block': + optional: true + '@react-email/code-inline': + optional: true + '@react-email/container': + optional: true + '@react-email/heading': + optional: true + '@react-email/hr': + optional: true + '@react-email/img': + optional: true + '@react-email/link': + optional: true + '@react-email/preview': + optional: true + + '@react-email/text@0.1.6': + resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@serwist/build@9.5.6': resolution: {integrity: sha512-/YUi2BKrvnIkYg8k/PW5N/lAR4N0h/F8eBaqCaDNOy2fdOiNCkvRaWq/ZaoYN5tocvNsMc7OSm7+m1aJqR7trQ==} engines: {node: '>=18.0.0'} @@ -504,6 +678,9 @@ packages: typescript: optional: true + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@supabase/auth-js@2.97.0': resolution: {integrity: sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==} engines: {node: '>=20.0.0'} @@ -1051,6 +1228,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -1067,6 +1248,19 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1087,6 +1281,10 @@ packages: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -1260,6 +1458,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1422,6 +1623,13 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1624,6 +1832,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1726,6 +1937,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1883,6 +2099,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1898,6 +2117,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1913,6 +2135,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.3: + resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -1928,10 +2153,19 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + pretty-bytes@6.1.1: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -1946,6 +2180,12 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-colorful@5.6.1: + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -1970,6 +2210,15 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + resend@6.9.2: + resolution: {integrity: sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2009,6 +2258,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2087,6 +2339,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2164,6 +2419,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.84.1: + resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -2252,6 +2510,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -2691,8 +2953,137 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@react-email/body@0.2.1(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/button@0.2.1(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/code-block@0.2.1(react@19.2.3)': + dependencies: + prismjs: 1.30.0 + react: 19.2.3 + + '@react-email/code-inline@0.0.6(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/column@0.0.14(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/components@1.0.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@react-email/body': 0.2.1(react@19.2.3) + '@react-email/button': 0.2.1(react@19.2.3) + '@react-email/code-block': 0.2.1(react@19.2.3) + '@react-email/code-inline': 0.0.6(react@19.2.3) + '@react-email/column': 0.0.14(react@19.2.3) + '@react-email/container': 0.0.16(react@19.2.3) + '@react-email/font': 0.0.10(react@19.2.3) + '@react-email/head': 0.0.13(react@19.2.3) + '@react-email/heading': 0.0.16(react@19.2.3) + '@react-email/hr': 0.0.12(react@19.2.3) + '@react-email/html': 0.0.12(react@19.2.3) + '@react-email/img': 0.0.12(react@19.2.3) + '@react-email/link': 0.0.13(react@19.2.3) + '@react-email/markdown': 0.0.18(react@19.2.3) + '@react-email/preview': 0.0.14(react@19.2.3) + '@react-email/render': 2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-email/row': 0.0.13(react@19.2.3) + '@react-email/section': 0.0.17(react@19.2.3) + '@react-email/tailwind': 2.0.5(@react-email/body@0.2.1(react@19.2.3))(@react-email/button@0.2.1(react@19.2.3))(@react-email/code-block@0.2.1(react@19.2.3))(@react-email/code-inline@0.0.6(react@19.2.3))(@react-email/container@0.0.16(react@19.2.3))(@react-email/heading@0.0.16(react@19.2.3))(@react-email/hr@0.0.12(react@19.2.3))(@react-email/img@0.0.12(react@19.2.3))(@react-email/link@0.0.13(react@19.2.3))(@react-email/preview@0.0.14(react@19.2.3))(@react-email/text@0.1.6(react@19.2.3))(react@19.2.3) + '@react-email/text': 0.1.6(react@19.2.3) + react: 19.2.3 + transitivePeerDependencies: + - react-dom + + '@react-email/container@0.0.16(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/font@0.0.10(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/head@0.0.13(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/heading@0.0.16(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/hr@0.0.12(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/html@0.0.12(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/img@0.0.12(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/link@0.0.13(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/markdown@0.0.18(react@19.2.3)': + dependencies: + marked: 15.0.12 + react: 19.2.3 + + '@react-email/preview@0.0.14(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/render@2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@react-email/row@0.0.13(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/section@0.0.17(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@react-email/tailwind@2.0.5(@react-email/body@0.2.1(react@19.2.3))(@react-email/button@0.2.1(react@19.2.3))(@react-email/code-block@0.2.1(react@19.2.3))(@react-email/code-inline@0.0.6(react@19.2.3))(@react-email/container@0.0.16(react@19.2.3))(@react-email/heading@0.0.16(react@19.2.3))(@react-email/hr@0.0.12(react@19.2.3))(@react-email/img@0.0.12(react@19.2.3))(@react-email/link@0.0.13(react@19.2.3))(@react-email/preview@0.0.14(react@19.2.3))(@react-email/text@0.1.6(react@19.2.3))(react@19.2.3)': + dependencies: + '@react-email/text': 0.1.6(react@19.2.3) + react: 19.2.3 + tailwindcss: 4.2.1 + optionalDependencies: + '@react-email/body': 0.2.1(react@19.2.3) + '@react-email/button': 0.2.1(react@19.2.3) + '@react-email/code-block': 0.2.1(react@19.2.3) + '@react-email/code-inline': 0.0.6(react@19.2.3) + '@react-email/container': 0.0.16(react@19.2.3) + '@react-email/heading': 0.0.16(react@19.2.3) + '@react-email/hr': 0.0.12(react@19.2.3) + '@react-email/img': 0.0.12(react@19.2.3) + '@react-email/link': 0.0.13(react@19.2.3) + '@react-email/preview': 0.0.14(react@19.2.3) + + '@react-email/text@0.1.6(react@19.2.3)': + dependencies: + react: 19.2.3 + '@rtsao/scc@1.1.0': {} + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + '@serwist/build@9.5.6(browserslist@4.28.1)(typescript@5.9.3)': dependencies: '@serwist/utils': 9.5.6(browserslist@4.28.1) @@ -2749,6 +3140,8 @@ snapshots: transitivePeerDependencies: - browserslist + '@stablelib/base64@1.0.1': {} + '@supabase/auth-js@2.97.0': dependencies: tslib: 2.8.1 @@ -3294,6 +3687,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -3312,6 +3707,24 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3331,6 +3744,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 @@ -3655,6 +4070,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3815,6 +4232,21 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -4017,6 +4449,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + leac@0.6.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4097,6 +4531,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@15.0.12: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -4257,6 +4693,11 @@ snapshots: dependencies: callsites: 3.1.0 + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4268,6 +4709,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + peberminta@0.9.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -4276,6 +4719,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.3: {} + postcss-value-parser@4.2.0: {} postcss@8.4.31: @@ -4292,8 +4737,12 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.8.1: {} + pretty-bytes@6.1.1: {} + prismjs@1.30.0: {} + proc-log@5.0.0: {} prop-types@15.8.1: @@ -4306,6 +4755,11 @@ snapshots: queue-microtask@1.2.3: {} + react-colorful@5.6.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -4337,6 +4791,13 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + resend@6.9.2(@react-email/render@2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): + dependencies: + postal-mime: 2.7.3 + svix: 1.84.1 + optionalDependencies: + '@react-email/render': 2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -4383,6 +4844,10 @@ snapshots: scheduler@0.27.0: {} + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + semver@6.3.1: {} semver@7.7.3: {} @@ -4496,6 +4961,11 @@ snapshots: stable-hash@0.0.5: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4597,6 +5067,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.84.1: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + tailwind-merge@2.6.1: {} tailwindcss@4.2.1: {} @@ -4731,6 +5206,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@10.0.0: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@4.0.2: {} diff --git a/src/app/(app)/account/page.tsx b/src/app/(app)/account/page.tsx index 0da1304..1aea34f 100644 --- a/src/app/(app)/account/page.tsx +++ b/src/app/(app)/account/page.tsx @@ -1,31 +1,31 @@ import React from "react"; -import { User, Settings } from "lucide-react"; +import { Settings } from "lucide-react"; +import { PageHeader } from "@/components/ui/PageHeader"; export default function AccountPage() { return ( -
-
-

Sua Conta

-

Gerencie suas informações, preferências e assinatura.

-
+
+ -
-
- -
+
+
+
+ +
-

Configurações

+

Configurações

-

- Módulo de gerenciamento e configurações da conta em construção. -

+

+ Módulo de gerenciamento e configurações da conta em construção. +

-
- - - - - Em desenvolvimento +
+ + + + + Em desenvolvimento +
diff --git a/src/app/(app)/accounts/page.tsx b/src/app/(app)/accounts/page.tsx index e24ab5d..358ebe0 100644 --- a/src/app/(app)/accounts/page.tsx +++ b/src/app/(app)/accounts/page.tsx @@ -1,12 +1,20 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { Plus, ChevronDown, ChevronRight } from "lucide-react"; import { motion, useScroll, useTransform } from "framer-motion"; import { cn } from "@/lib/utils"; import { AccountCard } from "@/components/ui/AccountCard"; import { AccountSlideOver } from "@/components/ui/AccountSlideOver"; +import { PageHeader } from "@/components/ui/PageHeader"; import { ArrowRight, X } from "lucide-react"; +import { CreateAccountModal } from "@/components/ui/CreateAccountModal"; +import { + fetchAccounts, + createAccountAction, + updateAccountAction, + deleteAccountAction, +} from "@/app/actions/accountActions"; // Mock Data const MOCK_GIRO = [ @@ -142,11 +150,52 @@ export default function AccountsPage() { const [transferIntent, setTransferIntent] = useState<{ sourceId: string; targetId: string } | null>(null); const [transferValue, setTransferValue] = useState(""); - const totalGiro = MOCK_GIRO.reduce((acc, curr) => acc + curr.balance, 0); - const totalCreditBills = MOCK_CREDIT.reduce((acc, curr) => acc + (curr.creditUsed || 0), 0); + // Create Account State + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [giroAccounts, setGiroAccounts] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [creditAccounts, setCreditAccounts] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [vaultAccounts, setVaultAccounts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSeeding, setIsSeeding] = useState(false); + + useEffect(() => { + let isMounted = true; + async function load() { + const data = await fetchAccounts(); + if (data && isMounted && data.length > 0) { + const mapped = data.map((account) => ({ + id: account.id, + name: account.name, + institution: account.institution || "", + category: account.category, + balance: account.balance, + colorHex: account.color_hex || "#8A05BE", + lastSyncedAt: new Date(account.updated_at), + members: [{ id: "u1", name: "Você", role: "owner" as const }], + creditUsed: account.category === "credit" ? 0 : undefined, + })); + + setGiroAccounts(mapped.filter((a) => ["checking", "wallet"].includes(a.category))); + setCreditAccounts(mapped.filter((a) => ["credit"].includes(a.category))); + setVaultAccounts(mapped.filter((a) => ["savings", "vault"].includes(a.category))); + } + if (isMounted) setIsLoading(false); + } + load(); + return () => { + isMounted = false; + }; + }, []); + + const totalGiro = giroAccounts.reduce((acc, curr) => acc + curr.balance, 0); + const totalCreditBills = creditAccounts.reduce((acc, curr) => acc + (curr.creditUsed || 0), 0); const realLiquidity = totalGiro - totalCreditBills; - const totalReserves = MOCK_VAULT.reduce((acc, curr) => acc + curr.balance, 0); + const totalReserves = vaultAccounts.reduce((acc, curr) => acc + curr.balance, 0); const formatCurrency = (val: number) => { return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(val); @@ -178,22 +227,128 @@ export default function AccountsPage() { e.preventDefault(); setDragOverId(null); if (draggedAccountId && draggedAccountId !== targetId) { + const tempAll = [...giroAccounts, ...creditAccounts, ...vaultAccounts]; + const targetAccount = tempAll.find((a) => a.id === targetId); + + if (targetAccount?.category === "credit") { + alert( + "Não é possível transferir saldo para um Cartão de Crédito. Ele deve estar vinculado a uma conta corrente.", + ); + setDraggedAccountId(null); + return; + } + setTransferIntent({ sourceId: draggedAccountId, targetId }); } setDraggedAccountId(null); }; // Combine all mock data to find specific accounts for the transfer modal - const ALL_ACCOUNTS = [...MOCK_GIRO, ...MOCK_CREDIT, ...MOCK_VAULT]; + const ALL_ACCOUNTS = [...giroAccounts, ...creditAccounts, ...vaultAccounts]; + + const handleTransferValueChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/\D/g, ""); + if (!value) { + setTransferValue(""); + return; + } + const numericValue = parseInt(value, 10) / 100; + const formatted = new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); + setTransferValue(formatted); + }; const executeTransfer = () => { - console.log(`Transferring ${transferValue} from ${transferIntent?.sourceId} to ${transferIntent?.targetId}`); + const value = parseFloat(transferValue.replace(/\./g, "").replace(",", ".")); + if (isNaN(value) || value <= 0 || !transferIntent) return; + + const { sourceId, targetId } = transferIntent; + + const updateBalance = (id: string, amount: number) => { + setGiroAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); + setCreditAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); + setVaultAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, balance: a.balance + amount } : a))); + }; + + updateBalance(sourceId, -value); + updateBalance(targetId, value); + setTransferIntent(null); setTransferValue(""); }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleUpdateAccount = async (id: string, updates: any) => { + const res = await updateAccountAction(id, updates); + if (res.success) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mapUpdate = (prev: any[]) => + prev.map((a) => (a.id === id ? { ...a, ...updates, colorHex: updates.color_hex || a.colorHex } : a)); + setGiroAccounts(mapUpdate); + setCreditAccounts(mapUpdate); + setVaultAccounts(mapUpdate); + } + }; + + const handleDeleteAccount = async (id: string) => { + const res = await deleteAccountAction(id); + if (res.success) { + setGiroAccounts((prev) => prev.filter((a) => a.id !== id)); + setCreditAccounts((prev) => prev.filter((a) => a.id !== id)); + setVaultAccounts((prev) => prev.filter((a) => a.id !== id)); + } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleCreateAccount = async (newAccount: any) => { + const accountInsert = { + name: newAccount.name, + institution: newAccount.institution, + category: newAccount.category, + balance: newAccount.balance || 0, + color_hex: newAccount.colorHex, + }; + const res = await createAccountAction(accountInsert as any); + + if (res.success && res.data) { + const dbAccount = { + ...newAccount, + id: res.data.id, + }; + + if ( + dbAccount.category === "checking" || + dbAccount.category === "savings" || + dbAccount.category === "wallet" + ) { + setGiroAccounts((prev) => [...prev, dbAccount]); + } else if (dbAccount.category === "credit") { + setCreditAccounts((prev) => [...prev, dbAccount]); + } else if (dbAccount.category === "vault") { + setVaultAccounts((prev) => [...prev, dbAccount]); + } + } + }; + + const handleSeedData = async () => { + setIsSeeding(true); + const defaults = [...MOCK_GIRO, ...MOCK_CREDIT, ...MOCK_VAULT]; + for (const acc of defaults) { + await createAccountAction({ + name: acc.name, + institution: acc.institution, + category: acc.category, + balance: acc.balance || 0, + color_hex: acc.colorHex, + } as any); + } + window.location.reload(); + }; + return ( -
+
{/* Subtle Background Glow w/ Parallax */} {/* Header Macro Context */} -
-
-

Liquidez Imediata

-
-

- {formatCurrency(realLiquidity)} -

- - Livre de faturas fechadas - -
- -
-

Patrimônio Alocado (Reservas):

- {formatCurrency(totalReserves)} + {formatCurrency(realLiquidity)}} + badge="Livre de faturas fechadas" + action={ +
+ {!isLoading && + giroAccounts.length === 0 && + creditAccounts.length === 0 && + vaultAccounts.length === 0 && ( + + )} +
+ } + > +
+

Patrimônio Alocado (Reservas):

+ {formatCurrency(totalReserves)}
- - -
+ {/* Accordions */}
- {MOCK_GIRO.map((account) => ( + {giroAccounts.map((account) => (
- {MOCK_CREDIT.map((account) => ( + {creditAccounts.map((account) => (
- {MOCK_VAULT.map((account) => ( + {vaultAccounts.map((account) => (
)} @@ -354,9 +523,10 @@ export default function AccountsPage() { R$ setTransferValue(e.target.value)} + onChange={handleTransferValueChange} autoFocus className="w-full text-center text-5xl font-bold bg-transparent border-none outline-none text-zinc-100 placeholder:text-zinc-800" placeholder="0,00" @@ -366,7 +536,10 @@ export default function AccountsPage() {
); })()} + + setIsCreateModalOpen(false)} + onCreate={handleCreateAccount} + />
); } diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index ad71c8b..cb83825 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,27 +1,32 @@ import React from "react"; import { Construction } from "lucide-react"; +import { PageHeader } from "@/components/ui/PageHeader"; export default function DashboardPage() { return ( -
-
-
- -
+
+ + +
+
+
+ +
-

Dashboard

+

Dashboard

-

- Esta área está atualmente em construção. Em breve você terá acesso a todas as métricas detalhadas de - fluxo de caixa aqui. -

+

+ Esta área está atualmente em construção. Em breve você terá acesso a todas as métricas + detalhadas de fluxo de caixa aqui. +

-
- - - - - Em desenvolvimento +
+ + + + + Em desenvolvimento +
diff --git a/src/app/(app)/investments/page.tsx b/src/app/(app)/investments/page.tsx index 02c2ef9..50cbf29 100644 --- a/src/app/(app)/investments/page.tsx +++ b/src/app/(app)/investments/page.tsx @@ -1,27 +1,32 @@ import React from "react"; import { TrendingUp } from "lucide-react"; +import { PageHeader } from "@/components/ui/PageHeader"; export default function InvestmentsPage() { return ( -
-
-
- -
+
+ + +
+
+
+ +
-

Investimentos

+

Investimentos

-

- Esta área está atualmente em construção. Em breve você terá acesso ao módulo de acompanhamento de - investimentos aqui. -

+

+ Esta área está atualmente em construção. Em breve você terá acesso ao módulo de acompanhamento + de investimentos aqui. +

-
- - - - - Em desenvolvimento +
+ + + + + Em desenvolvimento +
diff --git a/src/app/(app)/invoices/page.tsx b/src/app/(app)/invoices/page.tsx index ec322cc..8b51788 100644 --- a/src/app/(app)/invoices/page.tsx +++ b/src/app/(app)/invoices/page.tsx @@ -1,27 +1,32 @@ import React from "react"; import { Receipt } from "lucide-react"; +import { PageHeader } from "@/components/ui/PageHeader"; export default function InvoicesPage() { return ( -
-
-
- -
+
+ + +
+
+
+ +
-

Faturas

+

Faturas

-

- Esta área está atualmente em construção. Em breve você terá acesso a todas as suas faturas de - cartões aqui. -

+

+ Esta área está atualmente em construção. Em breve você terá acesso a todas as suas faturas de + cartões aqui. +

-
- - - - - Em desenvolvimento +
+ + + + + Em desenvolvimento +
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 885c8dd..8c9060a 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -1,26 +1,32 @@ import React from "react"; import { Settings } from "lucide-react"; +import { PageHeader } from "@/components/ui/PageHeader"; export default function SettingsPage() { return ( -
-
-
- -
+
+ + +
+
+
+ +
-

Configurações

+

Configurações

-

- Esta área está atualmente em construção. Em breve você terá acesso às configurações exclusivas aqui. -

+

+ Esta área está atualmente em construção. Em breve você terá acesso às configurações exclusivas + aqui. +

-
- - - - - Em desenvolvimento +
+ + + + + Em desenvolvimento +
diff --git a/src/app/actions/accountActions.ts b/src/app/actions/accountActions.ts new file mode 100644 index 0000000..3ee109a --- /dev/null +++ b/src/app/actions/accountActions.ts @@ -0,0 +1,69 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; +import { Database } from "@/types/supabase"; +import { randomUUID } from "crypto"; + +type AccountInsert = Database["public"]["Tables"]["accounts"]["Insert"]; +type AccountUpdate = Database["public"]["Tables"]["accounts"]["Update"]; + +export async function fetchAccounts() { + const supabase = await createClient(); + const { data, error } = await supabase.from("accounts").select("*").order("created_at", { ascending: false }); + + if (error) { + console.error("Error fetching accounts:", error); + return []; + } + + return data; +} + +export async function createAccountAction(account: AccountInsert) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return { success: false, error: "Usuário não autenticado." }; + } + + const id = account.id || randomUUID(); + const { error } = await supabase.from("accounts").insert({ ...account, id }); + + if (error) { + console.error("Error creating account:", error); + return { success: false, error: error.message }; + } + + revalidatePath("/accounts"); + return { success: true, data: { ...account, id } }; +} + +export async function updateAccountAction(id: string, updates: AccountUpdate) { + const supabase = await createClient(); + const { error } = await supabase.from("accounts").update(updates).eq("id", id); + + if (error) { + console.error("Error updating account:", error); + return { success: false, error: error.message }; + } + + revalidatePath("/accounts"); + return { success: true }; +} + +export async function deleteAccountAction(id: string) { + const supabase = await createClient(); + const { error } = await supabase.from("accounts").delete().eq("id", id); + + if (error) { + console.error("Error deleting account:", error); + return { success: false, error: error.message }; + } + + revalidatePath("/accounts"); + return { success: true }; +} diff --git a/src/app/actions/sendAccountInvite.ts b/src/app/actions/sendAccountInvite.ts new file mode 100644 index 0000000..80fb1d5 --- /dev/null +++ b/src/app/actions/sendAccountInvite.ts @@ -0,0 +1,40 @@ +"use server"; + +import { Resend } from "resend"; +import { AccountInviteEmail } from "@/components/emails/AccountInviteEmail"; + +const resend = new Resend(process.env.RESEND_API_KEY || "re_123"); + +export async function sendAccountInvite(data: { + email: string; + inviterName: string; + accountName: string; + role: string; +}) { + try { + const baseUrl = + process.env.NODE_ENV === "development" ? "http://localhost:9991" : "https://finiza.inovacode.dev"; + const inviteLink = `${baseUrl}/invite?email=${encodeURIComponent(data.email)}&role=${encodeURIComponent(data.role)}&account=${encodeURIComponent(data.accountName)}`; + + const { data: resendData, error } = await resend.emails.send({ + from: "Finiza ", // Replace with your verified domain + to: [data.email], + subject: `Convite para participar da conta: ${data.accountName}`, + react: AccountInviteEmail({ + inviterName: data.inviterName, + accountName: data.accountName, + inviteLink, + }) as React.ReactElement, + }); + + if (error) { + console.error(error); + return { success: false, error: error.message }; + } + + return { success: true, data: resendData }; + } catch (err: unknown) { + console.error(err); + return { success: false, error: err instanceof Error ? err.message : "Unknown error" }; + } +} diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx new file mode 100644 index 0000000..caf8f9b --- /dev/null +++ b/src/app/invite/page.tsx @@ -0,0 +1,22 @@ +import React, { Suspense } from "react"; +import { InviteView } from "@/components/invite/InviteView"; + +export const metadata = { + title: "Convite Pendente | Finiza", + description: "Aceite seu convite para participar de uma conta no Finiza.", +}; + +export default function InvitePage() { + return ( +
+ {/* Background igual ao da tela de Auth */} +
+ +
+ Carregando informações do convite...
}> + + +
+
+ ); +} diff --git a/src/components/auth/OtpForm.tsx b/src/components/auth/OtpForm.tsx index c656eca..c8042f8 100644 --- a/src/components/auth/OtpForm.tsx +++ b/src/components/auth/OtpForm.tsx @@ -15,6 +15,9 @@ export function OtpForm() { const supabase = createClient(); const router = useRouter(); + // Obtém parâmetros de busca da URL para manter o redirect + const searchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null; + const redirectTo = searchParams?.get("redirect_to") || "/dashboard"; const handleSendOtp = async (e: React.FormEvent) => { e.preventDefault(); @@ -52,7 +55,7 @@ export function OtpForm() { if (verifyError) throw verifyError; - router.push("/dashboard"); + router.push(redirectTo); router.refresh(); } catch (err) { setError((err as Error).message || "Código inválido ou expirado."); diff --git a/src/components/emails/AccountInviteEmail.tsx b/src/components/emails/AccountInviteEmail.tsx new file mode 100644 index 0000000..7d12b45 --- /dev/null +++ b/src/components/emails/AccountInviteEmail.tsx @@ -0,0 +1,212 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Link, + Preview, + Section, + Text, +} from "@react-email/components"; +import React from "react"; + +interface AccountInviteEmailProps { + inviterName: string; + accountName: string; + inviteLink: string; +} + +export const AccountInviteEmail: React.FC = ({ + inviterName = "Você", + accountName = "Conta Conjunta", + inviteLink = "https://finiza.inovacode.dev/invite/123", +}) => { + const previewText = `Você foi convidado(a) por ${inviterName} para participar da conta financeira ${accountName} no Finiza.`; + + return ( + + + + + {previewText} + +
+ +
+ + Finiza. + +
+ +
+ Você foi convidado! + + + Você foi convidado(a) por {inviterName}{" "} + para participar da conta financeira{" "} + {accountName} no Finiza. + + + + Ao aceitar este convite, você terá acesso à conta através da Sincronia Doméstica da + plataforma. + + +
+ +
+ +
+ + + Se você não esperava por este convite, por favor, ignore este email de forma segura. O + link expirará em breve. + +
+ +
+ + © {new Date().getFullYear()} Finiza. Todos os direitos reservados. + + + Se precisar de ajuda, entre em contato através do nosso{" "} + + suporte + + . + +
+
+
+ + + ); +}; + +const main = { + backgroundColor: "#09090b", + backgroundImage: "radial-gradient(circle at 50% -20%, rgba(120, 119, 198, 0.15), transparent)", + color: "#fafafa", + margin: "0", + padding: "0", + fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', +}; + +const containerWrapper = { + padding: "40px 20px", +}; + +const container = { + maxWidth: "600px", + margin: "0 auto", + backgroundColor: "#18181b", + background: "rgba(24, 24, 27, 0.6)", + borderRadius: "16px", + border: "1px solid rgba(255, 255, 255, 0.1)", + boxShadow: "0 8px 32px 0 rgba(0, 0, 0, 0.3)", + overflow: "hidden", +}; + +const header = { + padding: "32px 24px", + textAlign: "center" as const, + borderBottom: "1px solid rgba(255, 255, 255, 0.05)", +}; + +const logo = { + color: "#fafafa", + fontSize: "28px", + fontWeight: "700", + letterSpacing: "-1px", + textDecoration: "none", + margin: "0", +}; + +const logoAccent = { + color: "#34d399", +}; + +const content = { + padding: "40px 32px", +}; + +const title = { + fontSize: "22px", + fontWeight: "600", + color: "#fafafa", + marginTop: "0", + marginBottom: "16px", + textAlign: "center" as const, +}; + +const text = { + color: "#a1a1aa", + fontSize: "16px", + textAlign: "center" as const, + lineHeight: "1.6", + margin: "0 0 24px 0", +}; + +const buttonContainer = { + textAlign: "center" as const, + marginBottom: "32px", + marginTop: "32px", +}; + +const button = { + backgroundColor: "#10b981", + backgroundImage: "linear-gradient(to right, #34d399, #10b981)", + color: "#09090b", + fontWeight: "600", + textDecoration: "none", + padding: "14px 28px", + borderRadius: "8px", + fontSize: "16px", + boxShadow: "0 4px 14px 0 rgba(16, 185, 129, 0.39)", + display: "inline-block", +}; + +const divider = { + height: "1px", + backgroundColor: "rgba(255, 255, 255, 0.1)", + margin: "32px 0 24px 0", + border: "none", + width: "100%", +}; + +const footer = { + padding: "0 32px 32px", + textAlign: "center" as const, +}; + +const footerText = { + margin: "0 0 8px 0", + fontSize: "13px", + color: "#71717a", +}; + +const link = { + color: "#34d399", + textDecoration: "none", +}; + +export default AccountInviteEmail; diff --git a/src/components/invite/InviteView.tsx b/src/components/invite/InviteView.tsx new file mode 100644 index 0000000..1656b13 --- /dev/null +++ b/src/components/invite/InviteView.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { GlassCard } from "@/components/ui/GlassCard"; +import { Loader2, UserPlus, CheckCircle2, ArrowRight } from "lucide-react"; + +export function InviteView() { + const searchParams = useSearchParams(); + const router = useRouter(); + const supabase = createClient(); + + const email = searchParams?.get("email") || ""; + const role = searchParams?.get("role") || "Leitor"; + const account = searchParams?.get("account") || "Indefinida"; + + const [loading, setLoading] = useState(true); + const [userEmail, setUserEmail] = useState(null); + const [accepted, setAccepted] = useState(false); + + useEffect(() => { + let isMounted = true; + + const checkAuth = async () => { + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!isMounted) return; + + if (!session) { + // Usuário não autenticado: enviamos para /auth com a query redirect_to apontando de volta pra cá + const currentFullParams = searchParams ? searchParams.toString() : ""; + const inviteUrl = `/invite?${currentFullParams}`; + router.push(`/auth?redirect_to=${encodeURIComponent(inviteUrl)}`); + return; + } + + // Autenticado: continua a renderizar o convite + setUserEmail(session.user.email ?? null); + setLoading(false); + }; + + checkAuth(); + + return () => { + isMounted = false; + }; + }, [router, supabase, searchParams]); + + const handleAcceptInvite = async () => { + setLoading(true); + // FIXME: Aqui entraria a chamada de API real do Supabase + // para inserir o registro na tabela de relacionamentos (account_users) + await new Promise((resolve) => setTimeout(resolve, 1500)); + setAccepted(true); + setLoading(false); + }; + + if (loading) { + return ( + + +

Verificando informações...

+
+ ); + } + + if (accepted) { + return ( + +
+ +
+
+

Convite Aceito!

+

+ Agora você tem acesso à conta {account} com permissão + de {role}. +

+
+ +
+ ); + } + + return ( + +
+ +
+ +
+

Convite para Conta

+

+ Você foi convidado(a) para acessar a Sincronia Doméstica desta conta no Finiza. +

+
+ +
+
+ Conta Base + + {account} + +
+
+ Sua Permissão + {role} +
+
+ + {email && email !== userEmail && ( +
+ Aviso: O convite visava {email}, mas você está conectando como {userEmail}. Ao + prosseguir, o vínculo será feito ao perfil atual. +
+ )} + +
+ + +
+
+ ); +} diff --git a/src/components/ui/AccountSlideOver.tsx b/src/components/ui/AccountSlideOver.tsx index e50acc0..a76efe1 100644 --- a/src/components/ui/AccountSlideOver.tsx +++ b/src/components/ui/AccountSlideOver.tsx @@ -1,15 +1,20 @@ import React, { useState, useEffect } from "react"; -import { X, Check, Trash2, Edit2, Zap } from "lucide-react"; +import { X, Check, Trash2, Edit2, Zap, Plus, Loader2, ChevronDown } from "lucide-react"; +import { HexColorPicker } from "react-colorful"; import { cn } from "@/lib/utils"; +import { sendAccountInvite } from "@/app/actions/sendAccountInvite"; interface AccountSlideOverProps { isOpen: boolean; onClose: () => void; - accountId: string; + accountId?: string; name: string; institution: string; balance: number; colorHex: string; + category?: string; + onUpdate?: (id: string, updates: Record) => void; + onDelete?: (id: string) => void; } export function AccountSlideOver({ @@ -20,11 +25,63 @@ export function AccountSlideOver({ institution, balance, colorHex, + category = "checking", + onUpdate, + onDelete, }: AccountSlideOverProps) { + const [tempCategory, setTempCategory] = useState(category); + + useEffect(() => { + setTempCategory(category); + }, [category]); + const [activeTab, setActiveTab] = useState<"ajuste" | "historico" | "config">("ajuste"); - const [adjustedBalance, setAdjustedBalance] = useState(balance.toString()); + const [adjustedBalance, setAdjustedBalance] = useState(() => + new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(balance), + ); + + const handleBalanceChange = (e: React.ChangeEvent) => { + let value = e.target.value.replace(/\D/g, ""); + if (value === "") value = "0"; + const numericValue = parseInt(value, 10) / 100; + const formatted = new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); + setAdjustedBalance(formatted); + }; const [swipeLeftId, setSwipeLeftId] = useState(null); + const [isEditingInstitution, setIsEditingInstitution] = useState(false); + const [tempInstitution, setTempInstitution] = useState(institution); + + const [isAddingPerson, setIsAddingPerson] = useState(false); + const [isInviting, setIsInviting] = useState(false); + const [newPersonEmail, setNewPersonEmail] = useState(""); + const [addedPersons, setAddedPersons] = useState<{ email: string; role: string }[]>([]); + + const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [actualColor, setActualColor] = useState(colorHex); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const handleInstitutionSave = () => { + setIsEditingInstitution(false); + if (accountId && tempInstitution !== institution && onUpdate) { + onUpdate(accountId, { institution: tempInstitution }); + } + }; + + const handleColorSave = (newColor: string) => { + setActualColor(newColor); + if (accountId && newColor !== colorHex && onUpdate) { + onUpdate(accountId, { color_hex: newColor }); + } + }; + useEffect(() => { if (isOpen) { document.body.style.overflow = "hidden"; @@ -50,6 +107,46 @@ export function AccountSlideOver({ onClose(); }; + const handleInvitePerson = async () => { + if (!newPersonEmail) return; + + setIsInviting(true); + try { + // Fake API call or connect to server action + const res = await sendAccountInvite({ + email: newPersonEmail, + inviterName: "Você", // Default mock as the current auth user + accountName: name, + role: "Leitor", + }); + + if (res.success) { + // If ok, add dynamically + setAddedPersons((prev: { email: string; role: string }[]) => [ + ...prev, + { email: newPersonEmail, role: "Leitor" }, + ]); + setNewPersonEmail(""); + setIsAddingPerson(false); + } else { + console.error("Failed to send invite:", res.error); + alert("Falha ao enviar convite. " + res.error); + } + } catch (error) { + console.error("Unexpected error:", error); + alert("Erro inesperado ao enviar convite."); + setIsInviting(false); + } + }; + + const handleDeleteAccount = () => { + if (accountId && onDelete) { + onDelete(accountId); + } + setIsDeleteModalOpen(false); + onClose(); + }; + return ( <> {/* Backdrop */} @@ -69,15 +166,15 @@ export function AccountSlideOver({ > {/* Glow behind the sidebar */}
{/* Header */}

{name}

-

{institution}

+

{tempInstitution}

+
+ +
+ +
+ +
+
+
+ + {tempCategory === "credit" && ( +
+ +
+ +
+ +
+
+

+ O pagamento das faturas deste cartão será debitado desta conta no seu dashboard. +

+
+ )} +