diff --git a/bun.lockb b/bun.lockb index 4d5c249..7d464b6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package-lock.json b/package-lock.json index 0b0ca41..64b0398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@cloudflare/vite-plugin": "^1.25.5", "@hookform/resolvers": "^5.2.2", + "@lovable.dev/cloud-auth-js": "^1.1.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -34,8 +35,9 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@supabase/supabase-js": "^2.103.3", "@tailwindcss/vite": "^4.2.1", - "@tanstack/react-query": "^5.83.0", + "@tanstack/react-query": "^5.99.0", "@tanstack/react-router": "^1.168.0", "@tanstack/react-start": "^1.167.14", "@tanstack/router-plugin": "^1.167.10", @@ -44,12 +46,14 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.38.0", "input-otp": "^1.4.2", "lucide-react": "^0.575.0", "react": "^19.2.0", "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", - "react-hook-form": "^7.71.2", + "react-hook-form": "^7.72.1", + "react-imask": "^7.6.1", "react-resizable-panels": "^4.6.5", "recharts": "^2.15.4", "sonner": "^2.0.7", @@ -58,11 +62,11 @@ "tw-animate-css": "^1.3.4", "vaul": "^1.1.2", "vite-tsconfig-paths": "^6.0.2", - "zod": "^3.24.2" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.32.0", - "@lovable.dev/vite-tanstack-config": "^1.2.0", + "@lovable.dev/vite-tanstack-config": "^1.4.0", "@types/node": "^22.16.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -120,7 +124,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -352,6 +355,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", + "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.48.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -426,7 +441,6 @@ "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.32.2.tgz", "integrity": "sha512-sEI/jusfDvzHIL4oiBBV5iUxAXfTRvguecIDWQ/AxBgEjGO1ZslHOEy4rlxfgqrdRWtq0TM9m3oC+hT32hajEg==", "license": "MIT", - "peer": true, "dependencies": { "@cloudflare/unenv-preset": "2.16.0", "miniflare": "4.20260410.0", @@ -1768,13 +1782,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lovable.dev/cloud-auth-js": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.1.tgz", + "integrity": "sha512-80elU8dSJG6bho0Xnfj2oy53wp883nYXrG1Wy948LC/ZUaUQ0i9EGXQFmwTLOBFrWqxb6aNaOlZUvQ8BVGhjMQ==", + "license": "MIT" + }, + "node_modules/@lovable.dev/vite-plugin-hmr-gate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.0.0.tgz", + "integrity": "sha512-gWpiqRKwyE68ul7xlryGmoMJC3jJEWg/Pl80/Dl8h1Ajfw6VB3Quq8Mq17D5bAPL6NjxKlmA1g8GWJEjK2fDYg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": ">=5.0.0 <8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": false + } + } + }, "node_modules/@lovable.dev/vite-tanstack-config": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-1.2.0.tgz", - "integrity": "sha512-vvCb3I9mCnRwg09Re1Fr7liG23kHy2luaTGkGllS1tWQOhhlTUYj8jGRCkRd7MDjxg6TxpEQdOVgiK98Rm7sMQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-1.4.0.tgz", + "integrity": "sha512-OZmCj7t77kcKHEWANz7oeILdB1Y339h7cKz34SpNMxiyna+kzf8R57DZfBzMXY71A1UcOtCJYvSmr/wfDom8ag==", "dev": true, "license": "MIT", "dependencies": { + "@lovable.dev/vite-plugin-hmr-gate": "^1.0.0", "lovable-tagger": "1.1.13" }, "peerDependencies": { @@ -3550,6 +3586,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3563,6 +3600,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3576,6 +3614,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3589,6 +3628,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3602,6 +3642,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3615,6 +3656,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3628,6 +3670,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3641,6 +3684,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3654,6 +3698,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3667,6 +3712,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3680,6 +3726,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3693,6 +3740,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3706,6 +3754,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3719,6 +3768,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3732,6 +3782,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3745,6 +3796,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3758,6 +3810,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3771,6 +3824,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3784,6 +3838,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3797,6 +3852,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3810,6 +3866,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3823,6 +3880,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3836,6 +3894,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3849,6 +3908,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3862,6 +3922,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3892,6 +3953,113 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.103.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.3.tgz", + "integrity": "sha512-SMDJ4vg5jLXNEHdhN4J4ujSb203WangbDw1n3VaARH0ZqM51E6lJnoUAHlpQU9N7SzP0hfgghA9IvT8c7tGRfg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.103.3", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.3.tgz", + "integrity": "sha512-A2ZHi95GIRRlN9LGOSa/zGEIPg9taR1giDI9Gkfkgrcz0YmKV8ShiAplIrKsHQFdkzKxtsO3maJF0efL+i31mg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.103.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.3.tgz", + "integrity": "sha512-S0k/9FJVXDeejNfQLCJwRlm4IH8Wet/HEEdBTBpX6/G2o1eU/6CjQop/hJPZIwlQkI6D/zbHH8KymuCsBgy6jA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.103.3", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.3.tgz", + "integrity": "sha512-fUvKtSXMUk1BkApVwAurWtHF4Vzbb0UB9aC/fQXrRBek7Ta3Kaora+wHf/fGwFNQs7uRz+mvjIVpzLfpR32VXA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.103.3", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.3.tgz", + "integrity": "sha512-5bAIEubrw5keHcdKR2RTois0O1M2Ilx4UYuzOzc07G6mLGCPS/8t1nbC6Vq451pnxR3sK+rmtFHWb9CY/OPjAw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.103.3", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.3.tgz", + "integrity": "sha512-DuPiAz5pIJsTAQCt7B6bDZrnLzlq9+/5bta/GWTsgpLn6AkuZQcmYsQHYplv4skQ8U2raKY5HASQOu4KtYq9Qw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.103.3", + "@supabase/functions-js": "2.103.3", + "@supabase/postgrest-js": "2.103.3", + "@supabase/realtime-js": "2.103.3", + "@supabase/storage-js": "2.103.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tabby_ai/hijri-converter": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", @@ -4112,6 +4280,64 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -4149,7 +4375,6 @@ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", "license": "MIT", - "peer": true, "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", @@ -4226,7 +4451,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-start/-/react-start-1.167.39.tgz", "integrity": "sha512-57vpXGWhrisb69mgYBlEmpNvZCKYKix4jj4vqkiInCM4b97Y3SA8fwTPRXs7L/kEXGZCkCFzMR6EDiP15a3rWg==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/react-router": "1.168.21", "@tanstack/react-start-client": "1.166.38", @@ -4403,6 +4627,15 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/router-generator/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@tanstack/router-plugin": { "version": "1.167.22", "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.167.22.tgz", @@ -4458,6 +4691,15 @@ } } }, + "node_modules/@tanstack/router-plugin/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@tanstack/router-utils": { "version": "1.161.6", "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.161.6.tgz", @@ -4571,6 +4813,15 @@ "node": ">=6.9.0" } }, + "node_modules/@tanstack/start-plugin-core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@tanstack/start-server-core": { "version": "1.167.19", "resolved": "https://registry.npmjs.org/@tanstack/start-server-core/-/start-server-core-1.167.19.tgz", @@ -4749,6 +5000,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -4762,9 +5014,7 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4773,9 +5023,8 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4784,13 +5033,21 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", @@ -4836,7 +5093,6 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -5120,7 +5376,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5334,7 +5589,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5571,6 +5825,17 @@ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "license": "MIT" }, + "node_modules/core-js-pure": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5913,8 +6178,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -6065,7 +6329,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6126,7 +6389,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6454,6 +6716,33 @@ "dev": true, "license": "ISC" }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6624,6 +6913,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6646,6 +6944,18 @@ "node": ">= 4" } }, + "node_modules/imask": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/imask/-/imask-7.6.1.tgz", + "integrity": "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg==", + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs3": "^7.24.4" + }, + "engines": { + "npm": ">=4.0.0" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7362,6 +7672,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7384,6 +7709,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -7644,6 +7970,7 @@ "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -7659,7 +7986,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7818,7 +8144,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7895,7 +8220,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7927,7 +8251,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7940,7 +8263,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -7952,6 +8274,22 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-imask": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/react-imask/-/react-imask-7.6.1.tgz", + "integrity": "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA==", + "license": "MIT", + "dependencies": { + "imask": "^7.6.1", + "prop-types": "^15.8.1" + }, + "engines": { + "npm": ">=4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8200,6 +8538,7 @@ "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -8296,7 +8635,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz", "integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -9144,9 +9482,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9198,7 +9535,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unenv": { @@ -9206,7 +9542,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -9364,8 +9699,8 @@ "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9440,7 +9775,6 @@ "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", @@ -9457,6 +9791,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9473,6 +9808,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9489,6 +9825,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9505,6 +9842,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9521,6 +9859,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9537,6 +9876,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9553,6 +9893,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9569,6 +9910,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9585,6 +9927,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9601,6 +9944,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9617,6 +9961,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9633,6 +9978,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9649,6 +9995,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9665,6 +10012,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9681,6 +10029,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9697,6 +10046,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9713,6 +10063,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9729,6 +10080,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9745,6 +10097,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9761,6 +10114,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9777,6 +10131,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9793,6 +10148,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9809,6 +10165,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9825,6 +10182,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9841,6 +10199,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9857,6 +10216,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9870,6 +10230,7 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -9986,7 +10347,6 @@ "integrity": "sha512-T/GRD6Y5vN9g4CnGmOlfST1w7bj+1IjRFvX0K7CodZPJuPVPNPGhz8Wppah0WdT6A7I8Kad3zgZ2OkDdWtENrg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -10571,9 +10931,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index c5445e7..2bd89c9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@cloudflare/vite-plugin": "^1.25.5", "@hookform/resolvers": "^5.2.2", + "@lovable.dev/cloud-auth-js": "^1.1.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", diff --git a/src/components/mika/AuthCard.tsx b/src/components/mika/AuthCard.tsx new file mode 100644 index 0000000..12fcb0b --- /dev/null +++ b/src/components/mika/AuthCard.tsx @@ -0,0 +1,38 @@ +import { Link } from "@tanstack/react-router"; +import { Logo } from "./Logo"; +import { ThemeToggle } from "./ThemeToggle"; + +export function AuthCard({ + title, + subtitle, + children, + footer, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; + footer?: React.ReactNode; +}) { + return ( +
+
+ + + + +
+
+
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+ {footer &&
{footer}
} +
+
+
+ ); +} diff --git a/src/components/mika/EnterpriseLeadForm.tsx b/src/components/mika/EnterpriseLeadForm.tsx index dd86d4f..3b06129 100644 --- a/src/components/mika/EnterpriseLeadForm.tsx +++ b/src/components/mika/EnterpriseLeadForm.tsx @@ -6,6 +6,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { IMaskInput } from "react-imask"; import { toast } from "sonner"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -37,17 +39,22 @@ export function EnterpriseLeadForm() { const onSubmit = async (data: FormValues) => { setSubmitting(true); - try { - // Etapa 2: gravar em enterprise_leads via supabase.from(...).insert(...) - // Por ora: feedback otimista para o usuário. - await new Promise((r) => setTimeout(r, 700)); - toast.success("Recebemos seu contato! Falamos com você em até 1 dia útil."); - reset(); - } catch { - toast.error("Algo deu errado do nosso lado. Já estamos investigando."); - } finally { - setSubmitting(false); + const { error } = await supabase.from("enterprise_leads").insert({ + company_name: data.company_name, + contact_name: data.contact_name, + email: data.email, + phone: data.phone, + team_size: data.team_size, + message: data.message || null, + status: "new", + }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; } + toast.success("Recebemos seu contato! Falamos com você em até 1 dia útil."); + reset(); }; const phone = watch("phone") || ""; diff --git a/src/components/mika/GoogleButton.tsx b/src/components/mika/GoogleButton.tsx new file mode 100644 index 0000000..59c87b7 --- /dev/null +++ b/src/components/mika/GoogleButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { lovable } from "@/integrations/lovable/index"; +import { translateAuthError } from "@/lib/auth-errors"; + +export function GoogleButton({ redirectTo }: { redirectTo?: string }) { + const [loading, setLoading] = useState(false); + + const onClick = async () => { + setLoading(true); + try { + const url = redirectTo + ? `${window.location.origin}${redirectTo}` + : window.location.origin; + const result = await lovable.auth.signInWithOAuth("google", { redirect_uri: url }); + if (result.error) { + toast.error(translateAuthError(result.error.message)); + setLoading(false); + return; + } + if (result.redirected) return; + // tokens recebidos: redireciona + window.location.href = url; + } catch (e) { + toast.error(translateAuthError(e instanceof Error ? e.message : String(e))); + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/components/mika/LandingHeader.tsx b/src/components/mika/LandingHeader.tsx index 0c65c82..dc7cf5c 100644 --- a/src/components/mika/LandingHeader.tsx +++ b/src/components/mika/LandingHeader.tsx @@ -5,6 +5,7 @@ import { Link } from "@tanstack/react-router"; import { Menu } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetHeader } from "@/components/ui/sheet"; +import { useAuth } from "@/hooks/use-auth"; import { Logo } from "./Logo"; import { ThemeToggle } from "./ThemeToggle"; import { cn } from "@/lib/utils"; @@ -19,6 +20,7 @@ const NAV = [ export function LandingHeader() { const [scrolled, setScrolled] = useState(false); const [open, setOpen] = useState(false); + const { user } = useAuth(); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 8); @@ -53,12 +55,20 @@ export function LandingHeader() {
- - + {user ? ( + + ) : ( + <> + + + + )}
@@ -86,12 +96,20 @@ export function LandingHeader() { ))}
- - + {user ? ( + + ) : ( + <> + + + + )}
diff --git a/src/components/mika/PasswordStrengthMeter.tsx b/src/components/mika/PasswordStrengthMeter.tsx new file mode 100644 index 0000000..1c5fe85 --- /dev/null +++ b/src/components/mika/PasswordStrengthMeter.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { passwordStrength, passwordStrengthLabel } from "@/lib/password"; +import { cn } from "@/lib/utils"; + +export function PasswordStrengthMeter({ password }: { password: string }) { + if (!password) return null; + const score = passwordStrength(password); + const colors = [ + "bg-destructive", + "bg-destructive", + "bg-warning", + "bg-success", + ]; + return ( +
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+

+ Força: {passwordStrengthLabel(score)} +

+
+ ); +} diff --git a/src/components/mika/PlansSection.tsx b/src/components/mika/PlansSection.tsx index f06b2be..cedaa4c 100644 --- a/src/components/mika/PlansSection.tsx +++ b/src/components/mika/PlansSection.tsx @@ -3,91 +3,63 @@ import { useState } from "react"; import { Link } from "@tanstack/react-router"; import { Check } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger, +} from "@/components/ui/dialog"; import { EnterpriseLeadForm } from "./EnterpriseLeadForm"; import { cn } from "@/lib/utils"; -// Fallback estático — na Etapa 2 será substituído por query Supabase à tabela `plans`. -const STATIC_PLANS = [ - { - slug: "basic", - name: "Basic", - description: "Para começar a usar IA no dia a dia.", - monthly: 69.9, - yearly: 671.04, - highlighted: false, - is_enterprise: false, - features: [ - "1 agente pessoal no Telegram", - "Memória persistente básica", - "Integração Google Workspace", - "5 skills personalizadas", - "Suporte por e-mail", - ], - }, - { - slug: "starter", - name: "Starter", - description: "Para quem usa IA todos os dias.", - monthly: 199.9, - yearly: 1919.04, - highlighted: false, - is_enterprise: false, - features: [ - "Tudo do Basic", - "Memória avançada e contextual", - "Skills ilimitadas", - "Agendamentos automáticos", - "Suporte prioritário", - ], - }, - { - slug: "professional", - name: "Professional", - description: "Para profissionais e times pequenos.", - monthly: 399.9, - yearly: 3839.04, - highlighted: true, - is_enterprise: false, - features: [ - "Tudo do Starter", - "VPS dedicada de alta performance", - "Modelos de IA premium", - "Integrações personalizadas", - "Onboarding 1:1", - "Suporte em horário estendido", - ], - }, - { - slug: "enterprise", - name: "Enterprise", - description: "Para empresas com necessidades específicas.", - monthly: null, - yearly: null, - highlighted: false, - is_enterprise: true, - features: [ - "Tudo do Professional", - "Múltiplos agentes", - "SSO e gestão de equipe", - "SLA contratual", - "Treinamento da equipe", - "Account manager dedicado", - ], - }, -] as const; +interface PlanRow { + id: string; + slug: string; + name: string; + description: string | null; + price_monthly_brl: number | null; + price_yearly_brl: number | null; + features: string[]; + highlighted: boolean; + is_enterprise: boolean; + display_order: number; +} const fmtBRL = (v: number) => v.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +function usePlans() { + return useQuery({ + queryKey: ["plans"], + staleTime: 5 * 60_000, + queryFn: async (): Promise => { + const { data, error } = await supabase + .from("plans") + .select("*") + .order("display_order", { ascending: true }); + if (error) throw error; + return (data || []).map((p) => ({ + ...p, + features: Array.isArray(p.features) ? (p.features as string[]) : [], + })) as PlanRow[]; + }, + }); +} + export function PlansSection() { const [yearly, setYearly] = useState(false); + const { data: plans, isLoading } = usePlans(); return ( -
+

Planos para cada estágio

@@ -96,110 +68,140 @@ export function PlansSection() {

- + Mensal - - + + Anual - 20% off + + 20% off +
- {STATIC_PLANS.map((plan) => { - const price = yearly ? plan.yearly : plan.monthly; - const monthlyEquivalent = yearly && plan.yearly ? plan.yearly / 12 : null; - return ( -
- {plan.highlighted && ( - - Mais popular - - )} + {isLoading + ? Array.from({ length: 4 }).map((_, i) => ( + + )) + : (plans || []).map((plan) => { + const monthly = plan.price_monthly_brl ?? 0; + const yearlyPrice = plan.price_yearly_brl ?? 0; + const monthlyEquivalent = yearly && yearlyPrice ? yearlyPrice / 12 : monthly; + return ( +
+ {plan.highlighted && ( + + Mais popular + + )} -
-

{plan.name}

-

{plan.description}

-
+
+

{plan.name}

+

{plan.description}

+
-
- {plan.is_enterprise ? ( -

Sob consulta

- ) : ( - <> -
- R$ - - {fmtBRL(yearly && monthlyEquivalent ? monthlyEquivalent : (price as number))} - - /mês -
- {yearly && ( -

- R$ {fmtBRL(plan.yearly!)} cobrados anualmente -

+
+ {plan.is_enterprise ? ( +

Sob consulta

+ ) : ( + <> +
+ R$ + + {fmtBRL(monthlyEquivalent)} + + /mês +
+ {yearly && yearlyPrice > 0 && ( +

+ R$ {fmtBRL(yearlyPrice)} cobrados anualmente +

+ )} + )} - - )} -
+
-
    - {plan.features.map((f) => ( -
  • - - {f} -
  • - ))} -
+
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
-
- {plan.is_enterprise ? ( - - - - - - - Plano Enterprise - - Conte um pouco sobre sua empresa. Nosso time entra em contato em até 1 dia útil. - - - - - - ) : ( - + + + + Plano Enterprise + + Conte um pouco sobre sua empresa. Nosso time entra em contato em até + 1 dia útil. + + + + + + ) : ( + )} - > - - Assinar agora - - - )} -

- Sujeito à política de uso justo. Veja termos. -

-
-
- ); - })} +

+ Sujeito à política de uso justo. Veja termos. +

+
+ + ); + })}
diff --git a/src/components/mika/SubscriptionBanner.tsx b/src/components/mika/SubscriptionBanner.tsx new file mode 100644 index 0000000..f45b318 --- /dev/null +++ b/src/components/mika/SubscriptionBanner.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { format } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { AlertCircle, AlertTriangle, Info } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { SubscriptionRow } from "@/hooks/use-profile"; + +export function SubscriptionBanner({ subscription }: { subscription: SubscriptionRow | null }) { + if (!subscription) return null; + + const periodEnd = subscription.current_period_end ? new Date(subscription.current_period_end) : null; + const periodEndPassed = periodEnd && periodEnd.getTime() < Date.now(); + + // Pagamento pendente + if (subscription.status === "past_due") { + return ( + + Atualizar pagamento + + } + /> + ); + } + + // Cancelamento agendado + if (subscription.cancel_at_period_end && periodEnd && !periodEndPassed) { + return ( + + Reativar assinatura + + } + /> + ); + } + + // Cancelada e período já passou + if (subscription.status === "canceled" && periodEndPassed) { + return ( + + Assinar novamente + + } + /> + ); + } + + return null; +} + +function Banner({ + tone, + icon: Icon, + title, + description, + action, +}: { + tone: "destructive" | "warning" | "muted"; + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; + action: React.ReactNode; +}) { + const tones = { + destructive: "bg-destructive/10 border-destructive/30 text-destructive", + warning: "bg-warning/10 border-warning/30 text-warning", + muted: "bg-muted border-border text-foreground", + }; + return ( +
+
+ +
+

{title}

+

+ {description} +

+
+
+
{action}
+
+ ); +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts new file mode 100644 index 0000000..6522992 --- /dev/null +++ b/src/hooks/use-auth.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Session, User } from "@supabase/supabase-js"; +import { supabase } from "@/integrations/supabase/client"; + +interface AuthState { + user: User | null; + session: Session | null; + loading: boolean; +} + +export function useAuth(): AuthState { + const [state, setState] = useState({ + user: null, + session: null, + loading: true, + }); + + useEffect(() => { + // CRITICAL: subscribe BEFORE getSession (avoid missed events) + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + setState({ user: session?.user ?? null, session, loading: false }); + }); + + supabase.auth.getSession().then(({ data: { session } }) => { + setState({ user: session?.user ?? null, session, loading: false }); + }); + + return () => subscription.unsubscribe(); + }, []); + + return state; +} diff --git a/src/hooks/use-profile.ts b/src/hooks/use-profile.ts new file mode 100644 index 0000000..7855eec --- /dev/null +++ b/src/hooks/use-profile.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "./use-auth"; + +export interface Profile { + id: string; + full_name: string; + company_name: string | null; + cpf_cnpj: string | null; + phone: string | null; + avatar_url: string | null; + stripe_customer_id: string | null; + onboarding_completed: boolean; +} + +export function useProfile() { + const { user } = useAuth(); + + return useQuery({ + queryKey: ["profile", user?.id], + enabled: !!user, + queryFn: async (): Promise => { + if (!user) return null; + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .maybeSingle(); + if (error) throw error; + return data as Profile | null; + }, + }); +} + +export interface SubscriptionRow { + id: string; + user_id: string; + plan_id: string | null; + stripe_subscription_id: string | null; + status: "active" | "trialing" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "unpaid"; + billing_cycle: "monthly" | "yearly"; + current_period_start: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; +} + +export function useSubscription() { + const { user } = useAuth(); + + return useQuery({ + queryKey: ["subscription", user?.id], + enabled: !!user, + queryFn: async (): Promise => { + if (!user) return null; + const { data, error } = await supabase + .from("subscriptions") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) throw error; + return data as SubscriptionRow | null; + }, + }); +} diff --git a/src/integrations/lovable/index.ts b/src/integrations/lovable/index.ts new file mode 100644 index 0000000..4561eb5 --- /dev/null +++ b/src/integrations/lovable/index.ts @@ -0,0 +1,38 @@ +// This file is auto-generated by Lovable. Do not modify it. + +import { createLovableAuth } from "@lovable.dev/cloud-auth-js"; +import { supabase } from "../supabase/client"; +const lovableAuth = createLovableAuth(); + +type SignInOptions = { + redirect_uri?: string; + extraParams?: Record; +}; + +export const lovable = { + auth: { + signInWithOAuth: async (provider: "google" | "apple" | "microsoft", opts?: SignInOptions) => { + const result = await lovableAuth.signInWithOAuth(provider, { + redirect_uri: opts?.redirect_uri, + extraParams: { + ...opts?.extraParams, + }, + }); + + if (result.redirected) { + return result; + } + + if (result.error) { + return result; + } + + try { + await supabase.auth.setSession(result.tokens); + } catch (e) { + return { error: e instanceof Error ? e : new Error(String(e)) }; + } + return result; + }, + }, +}; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index ba7e5bb..c3e0690 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,7 +14,257 @@ export type Database = { } public: { Tables: { - [_ in never]: never + agent_instances: { + Row: { + container_name: string | null + created_at: string + id: string + status: string + telegram_bot_token_vault_id: string | null + telegram_bot_username: string | null + updated_at: string + user_id: string + uuid_tenant: string + vps_host: string | null + } + Insert: { + container_name?: string | null + created_at?: string + id?: string + status?: string + telegram_bot_token_vault_id?: string | null + telegram_bot_username?: string | null + updated_at?: string + user_id: string + uuid_tenant?: string + vps_host?: string | null + } + Update: { + container_name?: string | null + created_at?: string + id?: string + status?: string + telegram_bot_token_vault_id?: string | null + telegram_bot_username?: string | null + updated_at?: string + user_id?: string + uuid_tenant?: string + vps_host?: string | null + } + Relationships: [ + { + foreignKeyName: "agent_instances_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + enterprise_leads: { + Row: { + company_name: string + contact_name: string + created_at: string + email: string + id: string + message: string | null + phone: string | null + status: string + team_size: string + } + Insert: { + company_name: string + contact_name: string + created_at?: string + email: string + id?: string + message?: string | null + phone?: string | null + status?: string + team_size: string + } + Update: { + company_name?: string + contact_name?: string + created_at?: string + email?: string + id?: string + message?: string | null + phone?: string | null + status?: string + team_size?: string + } + Relationships: [] + } + plans: { + Row: { + created_at: string + description: string | null + display_order: number + features: Json + highlighted: boolean + id: string + is_enterprise: boolean + name: string + price_monthly_brl: number | null + price_yearly_brl: number | null + slug: string + stripe_price_id_monthly: string | null + stripe_price_id_yearly: string | null + } + Insert: { + created_at?: string + description?: string | null + display_order?: number + features?: Json + highlighted?: boolean + id?: string + is_enterprise?: boolean + name: string + price_monthly_brl?: number | null + price_yearly_brl?: number | null + slug: string + stripe_price_id_monthly?: string | null + stripe_price_id_yearly?: string | null + } + Update: { + created_at?: string + description?: string | null + display_order?: number + features?: Json + highlighted?: boolean + id?: string + is_enterprise?: boolean + name?: string + price_monthly_brl?: number | null + price_yearly_brl?: number | null + slug?: string + stripe_price_id_monthly?: string | null + stripe_price_id_yearly?: string | null + } + Relationships: [] + } + profiles: { + Row: { + avatar_url: string | null + company_name: string | null + cpf_cnpj: string | null + created_at: string + full_name: string + id: string + onboarding_completed: boolean + phone: string | null + stripe_customer_id: string | null + updated_at: string + } + Insert: { + avatar_url?: string | null + company_name?: string | null + cpf_cnpj?: string | null + created_at?: string + full_name?: string + id: string + onboarding_completed?: boolean + phone?: string | null + stripe_customer_id?: string | null + updated_at?: string + } + Update: { + avatar_url?: string | null + company_name?: string | null + cpf_cnpj?: string | null + created_at?: string + full_name?: string + id?: string + onboarding_completed?: boolean + phone?: string | null + stripe_customer_id?: string | null + updated_at?: string + } + Relationships: [] + } + stripe_webhook_events: { + Row: { + event_type: string + id: string + payload: Json | null + processed_at: string + stripe_event_id: string + } + Insert: { + event_type: string + id?: string + payload?: Json | null + processed_at?: string + stripe_event_id: string + } + Update: { + event_type?: string + id?: string + payload?: Json | null + processed_at?: string + stripe_event_id?: string + } + Relationships: [] + } + subscriptions: { + Row: { + billing_cycle: string + cancel_at_period_end: boolean + created_at: string + current_period_end: string | null + current_period_start: string | null + id: string + plan_id: string | null + status: string + stripe_subscription_id: string | null + updated_at: string + user_id: string + } + Insert: { + billing_cycle: string + cancel_at_period_end?: boolean + created_at?: string + current_period_end?: string | null + current_period_start?: string | null + id?: string + plan_id?: string | null + status: string + stripe_subscription_id?: string | null + updated_at?: string + user_id: string + } + Update: { + billing_cycle?: string + cancel_at_period_end?: boolean + created_at?: string + current_period_end?: string | null + current_period_start?: string | null + id?: string + plan_id?: string | null + status?: string + stripe_subscription_id?: string | null + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "subscriptions_plan_id_fkey" + columns: ["plan_id"] + isOneToOne: false + referencedRelation: "plans" + referencedColumns: ["id"] + }, + { + foreignKeyName: "subscriptions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } } Views: { [_ in never]: never diff --git a/src/lib/auth-errors.ts b/src/lib/auth-errors.ts new file mode 100644 index 0000000..27c8c0a --- /dev/null +++ b/src/lib/auth-errors.ts @@ -0,0 +1,26 @@ +/** + * Traduz mensagens de erro do Supabase Auth para mensagens em português específicas. + */ +export function translateAuthError(message: string | undefined): string { + if (!message) return "Algo deu errado. Tente novamente."; + const m = message.toLowerCase(); + + if (m.includes("invalid login credentials") || m.includes("invalid_credentials")) + return "E-mail ou senha incorretos. Tente novamente."; + if (m.includes("user already registered") || m.includes("already been registered") || m.includes("already exists")) + return "Esse e-mail já possui cadastro. Deseja fazer login?"; + if (m.includes("password") && (m.includes("short") || m.includes("weak") || m.includes("characters"))) + return "A senha precisa de pelo menos 8 caracteres, 1 maiúscula e 1 número."; + if (m.includes("email") && m.includes("invalid")) + return "Formato de e-mail inválido."; + if (m.includes("rate limit") || m.includes("too many requests")) + return "Muitas tentativas. Aguarde 1 minuto e tente novamente."; + if (m.includes("expired") || m.includes("jwt") || m.includes("session")) + return "Sua sessão expirou. Faça login novamente."; + if (m.includes("email not confirmed")) + return "Confirme seu e-mail antes de entrar. Verifique sua caixa de entrada."; + if (m.includes("pwned") || m.includes("compromised") || m.includes("breach")) + return "Esta senha apareceu em vazamentos públicos. Escolha outra mais forte."; + + return message; +} diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..58ffa5f --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,14 @@ +export type PasswordStrength = 0 | 1 | 2 | 3; + +export function passwordStrength(password: string): PasswordStrength { + let score = 0; + if (password.length >= 8) score++; + if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++; + if (/\d/.test(password) && /[^A-Za-z0-9]/.test(password)) score++; + if (password.length >= 12) score = Math.min(3, score + 1) as PasswordStrength; + return Math.min(3, score) as PasswordStrength; +} + +export function passwordStrengthLabel(s: PasswordStrength): string { + return ["Muito fraca", "Fraca", "Média", "Forte"][s]; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 05e7907..dbc2b0c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,14 +10,35 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SignupRouteImport } from './routes/signup' +import { Route as RedefinirSenhaRouteImport } from './routes/redefinir-senha' +import { Route as RecuperarSenhaRouteImport } from './routes/recuperar-senha' +import { Route as PainelRouteImport } from './routes/painel' import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' +import { Route as PainelIndexRouteImport } from './routes/painel.index' +import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento' +import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes' const SignupRoute = SignupRouteImport.update({ id: '/signup', path: '/signup', getParentRoute: () => rootRouteImport, } as any) +const RedefinirSenhaRoute = RedefinirSenhaRouteImport.update({ + id: '/redefinir-senha', + path: '/redefinir-senha', + getParentRoute: () => rootRouteImport, +} as any) +const RecuperarSenhaRoute = RecuperarSenhaRouteImport.update({ + id: '/recuperar-senha', + path: '/recuperar-senha', + getParentRoute: () => rootRouteImport, +} as any) +const PainelRoute = PainelRouteImport.update({ + id: '/painel', + path: '/painel', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -28,34 +49,96 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const PainelIndexRoute = PainelIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PainelRoute, +} as any) +const PainelFaturamentoRoute = PainelFaturamentoRouteImport.update({ + id: '/faturamento', + path: '/faturamento', + getParentRoute: () => PainelRoute, +} as any) +const PainelConfiguracoesRoute = PainelConfiguracoesRouteImport.update({ + id: '/configuracoes', + path: '/configuracoes', + getParentRoute: () => PainelRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute + '/painel': typeof PainelRouteWithChildren + '/recuperar-senha': typeof RecuperarSenhaRoute + '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/painel/configuracoes': typeof PainelConfiguracoesRoute + '/painel/faturamento': typeof PainelFaturamentoRoute + '/painel/': typeof PainelIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute + '/recuperar-senha': typeof RecuperarSenhaRoute + '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/painel/configuracoes': typeof PainelConfiguracoesRoute + '/painel/faturamento': typeof PainelFaturamentoRoute + '/painel': typeof PainelIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/login': typeof LoginRoute + '/painel': typeof PainelRouteWithChildren + '/recuperar-senha': typeof RecuperarSenhaRoute + '/redefinir-senha': typeof RedefinirSenhaRoute '/signup': typeof SignupRoute + '/painel/configuracoes': typeof PainelConfiguracoesRoute + '/painel/faturamento': typeof PainelFaturamentoRoute + '/painel/': typeof PainelIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/login' | '/signup' + fullPaths: + | '/' + | '/login' + | '/painel' + | '/recuperar-senha' + | '/redefinir-senha' + | '/signup' + | '/painel/configuracoes' + | '/painel/faturamento' + | '/painel/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/login' | '/signup' - id: '__root__' | '/' | '/login' | '/signup' + to: + | '/' + | '/login' + | '/recuperar-senha' + | '/redefinir-senha' + | '/signup' + | '/painel/configuracoes' + | '/painel/faturamento' + | '/painel' + id: + | '__root__' + | '/' + | '/login' + | '/painel' + | '/recuperar-senha' + | '/redefinir-senha' + | '/signup' + | '/painel/configuracoes' + | '/painel/faturamento' + | '/painel/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute LoginRoute: typeof LoginRoute + PainelRoute: typeof PainelRouteWithChildren + RecuperarSenhaRoute: typeof RecuperarSenhaRoute + RedefinirSenhaRoute: typeof RedefinirSenhaRoute SignupRoute: typeof SignupRoute } @@ -68,6 +151,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SignupRouteImport parentRoute: typeof rootRouteImport } + '/redefinir-senha': { + id: '/redefinir-senha' + path: '/redefinir-senha' + fullPath: '/redefinir-senha' + preLoaderRoute: typeof RedefinirSenhaRouteImport + parentRoute: typeof rootRouteImport + } + '/recuperar-senha': { + id: '/recuperar-senha' + path: '/recuperar-senha' + fullPath: '/recuperar-senha' + preLoaderRoute: typeof RecuperarSenhaRouteImport + parentRoute: typeof rootRouteImport + } + '/painel': { + id: '/painel' + path: '/painel' + fullPath: '/painel' + preLoaderRoute: typeof PainelRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -82,14 +186,62 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/painel/': { + id: '/painel/' + path: '/' + fullPath: '/painel/' + preLoaderRoute: typeof PainelIndexRouteImport + parentRoute: typeof PainelRoute + } + '/painel/faturamento': { + id: '/painel/faturamento' + path: '/faturamento' + fullPath: '/painel/faturamento' + preLoaderRoute: typeof PainelFaturamentoRouteImport + parentRoute: typeof PainelRoute + } + '/painel/configuracoes': { + id: '/painel/configuracoes' + path: '/configuracoes' + fullPath: '/painel/configuracoes' + preLoaderRoute: typeof PainelConfiguracoesRouteImport + parentRoute: typeof PainelRoute + } } } +interface PainelRouteChildren { + PainelConfiguracoesRoute: typeof PainelConfiguracoesRoute + PainelFaturamentoRoute: typeof PainelFaturamentoRoute + PainelIndexRoute: typeof PainelIndexRoute +} + +const PainelRouteChildren: PainelRouteChildren = { + PainelConfiguracoesRoute: PainelConfiguracoesRoute, + PainelFaturamentoRoute: PainelFaturamentoRoute, + PainelIndexRoute: PainelIndexRoute, +} + +const PainelRouteWithChildren = + PainelRoute._addFileChildren(PainelRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginRoute: LoginRoute, + PainelRoute: PainelRouteWithChildren, + RecuperarSenhaRoute: RecuperarSenhaRoute, + RedefinirSenhaRoute: RedefinirSenhaRoute, SignupRoute: SignupRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/router.tsx b/src/router.tsx index adcf1a9..bde43d8 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,4 +1,5 @@ import { createRouter, useRouter } from "@tanstack/react-router"; +import { QueryClient } from "@tanstack/react-query"; import { routeTree } from "./routeTree.gen"; function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => void }) { @@ -23,9 +24,9 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo />
-

Something went wrong

+

Algo deu errado

- An unexpected error occurred. Please try again. + Ocorreu um erro inesperado. Tente novamente.

{import.meta.env.DEV && error.message && (
@@ -38,15 +39,15 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
               router.invalidate();
               reset();
             }}
-            className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
+            className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-dark"
           >
-            Try again
+            Tentar novamente
           
           
-            Go home
+            Ir para o início
           
         
       
@@ -55,9 +56,20 @@ function DefaultErrorComponent({ error, reset }: { error: Error; reset: () => vo
 }
 
 export const getRouter = () => {
+  // Fresh QueryClient per request — never module-level (vaza dados entre SSR requests)
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: {
+        staleTime: 30_000,
+        retry: 1,
+        refetchOnWindowFocus: false,
+      },
+    },
+  });
+
   const router = createRouter({
     routeTree,
-    context: {},
+    context: { queryClient },
     scrollRestoration: true,
     defaultPreloadStaleTime: 0,
     defaultErrorComponent: DefaultErrorComponent,
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index ad8467f..3605010 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -1,9 +1,15 @@
-import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
+import { Outlet, createRootRouteWithContext, HeadContent, Scripts, Link } from "@tanstack/react-router";
+import type { QueryClient } from "@tanstack/react-query";
+import { QueryClientProvider } from "@tanstack/react-query";
 import { Toaster } from "@/components/ui/sonner";
 import { ThemeProvider } from "@/components/theme-provider";
 
 import appCss from "../styles.css?url";
 
+interface RouterContext {
+  queryClient: QueryClient;
+}
+
 function NotFoundComponent() {
   return (
     
@@ -14,19 +20,19 @@ function NotFoundComponent() { A página que você procura não existe ou foi movida.

- Ir para o início - +
); } -export const Route = createRootRoute({ +export const Route = createRootRouteWithContext()({ head: () => ({ meta: [ { charSet: "utf-8" }, @@ -88,10 +94,13 @@ function RootShell({ children }: { children: React.ReactNode }) { } function RootComponent() { + const { queryClient } = Route.useRouteContext(); return ( - - - - + + + + + + ); } diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 8f48862..269ffd6 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,21 +1,106 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { Logo } from "@/components/mika/Logo"; +"use client"; + +import { useState } from "react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; +import { AuthCard } from "@/components/mika/AuthCard"; +import { GoogleButton } from "@/components/mika/GoogleButton"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type LoginSearch = { redirect?: string }; + +const schema = z.object({ + email: z.string().email("Formato de e-mail inválido."), + password: z.string().min(1, "Informe sua senha."), +}); +type FormValues = z.infer; export const Route = createFileRoute("/login")({ - component: LoginPlaceholder, + validateSearch: (search: Record): LoginSearch => ({ + redirect: typeof search.redirect === "string" ? search.redirect : undefined, + }), + component: LoginPage, }); -function LoginPlaceholder() { +function LoginPage() { + const { redirect } = Route.useSearch(); + const navigate = useNavigate(); + const [submitting, setSubmitting] = useState(false); + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormValues) => { + setSubmitting(true); + const { error } = await supabase.auth.signInWithPassword({ + email: data.email, + password: data.password, + }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + toast.success("Bem-vindo de volta!"); + navigate({ to: (redirect as "/painel") || "/painel" }); + }; + return ( -
-
- -

Login

-

- A página de login completa será implementada na Etapa 2 (Auth + Supabase). -

- ← Voltar para o início + + Não tem conta?{" "} + + Criar conta + + + } + > +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+
+ + + Esqueci minha senha + +
+ + {errors.password &&

{errors.password.message}

} +
+ + +
+ +
+
+ ou +
-
+ + + ); } diff --git a/src/routes/painel.configuracoes.tsx b/src/routes/painel.configuracoes.tsx new file mode 100644 index 0000000..9be147e --- /dev/null +++ b/src/routes/painel.configuracoes.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import { useState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { IMaskInput } from "react-imask"; +import { toast } from "sonner"; +import { Loader2, LogOut } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { useProfile } from "@/hooks/use-profile"; +import { translateAuthError } from "@/lib/auth-errors"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const Route = createFileRoute("/painel/configuracoes")({ + component: SettingsPage, +}); + +const profileSchema = z.object({ + full_name: z.string().min(2, "Informe seu nome completo.").max(120), + company_name: z.string().max(120).optional().or(z.literal("")), + cpf_cnpj: z.string().optional().or(z.literal("")), + phone: z.string().optional().or(z.literal("")), +}); +type ProfileForm = z.infer; + +function SettingsPage() { + const { user } = useAuth(); + const { data: profile, isLoading } = useProfile(); + const queryClient = useQueryClient(); + + const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({ + resolver: zodResolver(profileSchema), + }); + + useEffect(() => { + if (profile) { + reset({ + full_name: profile.full_name, + company_name: profile.company_name ?? "", + cpf_cnpj: profile.cpf_cnpj ?? "", + phone: profile.phone ?? "", + }); + } + }, [profile, reset]); + + const cpfCnpj = watch("cpf_cnpj") || ""; + const phone = watch("phone") || ""; + const cpfCnpjDigits = cpfCnpj.replace(/\D/g, ""); + const cpfCnpjMask = cpfCnpjDigits.length > 11 ? "00.000.000/0000-00" : "000.000.000-00"; + + const updateProfile = useMutation({ + mutationFn: async (data: ProfileForm) => { + if (!user) throw new Error("Sem sessão"); + const { error } = await supabase + .from("profiles") + .update({ + full_name: data.full_name, + company_name: data.company_name || null, + cpf_cnpj: data.cpf_cnpj || null, + phone: data.phone || null, + }) + .eq("id", user.id); + if (error) throw error; + }, + onSuccess: () => { + toast.success("Perfil atualizado com sucesso."); + queryClient.invalidateQueries({ queryKey: ["profile"] }); + }, + onError: (e: Error) => toast.error(translateAuthError(e.message)), + }); + + const signOutAll = async () => { + const { error } = await supabase.auth.signOut({ scope: "global" }); + if (error) toast.error(translateAuthError(error.message)); + else toast.success("Saiu de todos os dispositivos."); + }; + + if (isLoading) return ; + + return ( +
+
+

Configurações

+

Gerencie seu perfil e segurança.

+
+ +
+

Perfil

+

Informações para faturamento e suporte.

+ +
updateProfile.mutate(d))} className="space-y-4"> +
+ + + {errors.full_name &&

{errors.full_name.message}

} +
+ +
+ + +
+ +
+
+ + setValue("cpf_cnpj", v as string, { shouldValidate: true })} + placeholder="000.000.000-00" + className="flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> +
+ +
+ + setValue("phone", v as string, { shouldValidate: true })} + placeholder="(11) 99999-9999" + className="flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> +
+
+ + +
+
+ +
+

Segurança

+

+ Para alterar sua senha, use o link de recuperação no e-mail. +

+ +
+
+ ); +} diff --git a/src/routes/painel.faturamento.tsx b/src/routes/painel.faturamento.tsx new file mode 100644 index 0000000..331d81d --- /dev/null +++ b/src/routes/painel.faturamento.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import { format } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { CreditCard, ExternalLink } from "lucide-react"; +import { useSubscription, useProfile } from "@/hooks/use-profile"; +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const Route = createFileRoute("/painel/faturamento")({ + component: BillingPage, +}); + +function BillingPage() { + const { data: subscription, isLoading } = useSubscription(); + const { data: profile } = useProfile(); + + const { data: plan } = useQuery({ + queryKey: ["plan", subscription?.plan_id], + enabled: !!subscription?.plan_id, + queryFn: async () => { + const { data } = await supabase + .from("plans") + .select("*") + .eq("id", subscription!.plan_id!) + .maybeSingle(); + return data; + }, + }); + + return ( +
+
+

Faturamento

+

+ Gerencie sua assinatura, método de pagamento e histórico. +

+
+ + {isLoading ? ( + + ) : ( +
+

Plano atual

+ {subscription && plan ? ( +
+ + + + +
+ ) : ( +

+ Você ainda não possui uma assinatura ativa. +

+ )} + +
+ )} + +
+

Histórico de pagamentos

+

+ Seu histórico aparecerá aqui após a primeira cobrança. +

+
+ +
+

Método de pagamento

+
+ + Nenhum método cadastrado +
+ +
+
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function statusLabel(s: string): string { + return { + active: "Ativa", + trialing: "Em período de teste", + past_due: "Pagamento pendente", + canceled: "Cancelada", + incomplete: "Em provisionamento", + incomplete_expired: "Expirada", + unpaid: "Não paga", + }[s] || s; +} diff --git a/src/routes/painel.index.tsx b/src/routes/painel.index.tsx new file mode 100644 index 0000000..e339ea9 --- /dev/null +++ b/src/routes/painel.index.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react"; +import { useSubscription } from "@/hooks/use-profile"; +import { useProfile } from "@/hooks/use-profile"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SubscriptionBanner } from "@/components/mika/SubscriptionBanner"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/painel/")({ + component: DashboardPage, +}); + +function DashboardPage() { + const { data: subscription, isLoading } = useSubscription(); + const { data: profile } = useProfile(); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + const firstName = (profile?.full_name || "").split(" ")[0] || "por aqui"; + + return ( +
+ + +
+

Olá, {firstName} 👋

+

+ {subscription + ? "Acompanhe abaixo o status do seu agente Mika." + : "Vamos colocar seu agente Mika no ar."} +

+
+ + {!subscription && } + + {subscription && (subscription.status === "incomplete" || subscription.status === "active") && ( + + )} +
+ ); +} + +function NoSubscriptionCard() { + return ( +
+
+ +
+

Escolha um plano para começar

+

+ Em poucos minutos seu agente Mika estará disponível no Telegram, com memória persistente e + skills personalizadas. +

+ +
+ ); +} + +function ProvisioningCard() { + const steps = [ + { label: "Provisionar container na VPS", state: "active" as const }, + { label: "Configurar modelo de IA", state: "pending" as const }, + { label: "Conectar seu Telegram", state: "pending" as const }, + { label: "Personalizar seu agente", state: "pending" as const }, + ]; + return ( +
+
+
+ +
+
+

Seu agente Mika está sendo provisionado

+

+ Você receberá um e-mail quando estiver pronto — geralmente em até 10 minutos. +

+
+
+ +
    + {steps.map((step, i) => ( +
  1. +
    +
    + {step.state === "active" ? ( + + ) : step.state === "pending" ? ( + i + 1 + ) : ( + + )} +
    +

    + {step.label} +

    +
    +
  2. + ))} +
+
+ ); +} diff --git a/src/routes/painel.tsx b/src/routes/painel.tsx new file mode 100644 index 0000000..f2df24b --- /dev/null +++ b/src/routes/painel.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { createFileRoute, Outlet, redirect, Link, useNavigate, useLocation } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { + Bot, + CreditCard, + Home, + LogOut, + Menu, + Plug, + Settings, + Sparkles, + User, +} from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { useProfile } from "@/hooks/use-profile"; +import { Logo } from "@/components/mika/Logo"; +import { ThemeToggle } from "@/components/mika/ThemeToggle"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/painel")({ + beforeLoad: () => { + // Auth real é validada client-side abaixo (Supabase usa localStorage). + // Mantemos beforeLoad como hook para futuro SSR auth. + }, + component: PainelLayout, +}); + +interface NavItem { + to: "/painel" | "/painel/faturamento" | "/painel/configuracoes"; + label: string; + icon: React.ComponentType<{ className?: string }>; + disabled?: boolean; +} + +interface DisabledNavItem { + to: null; + label: string; + icon: React.ComponentType<{ className?: string }>; + disabled: true; +} + +const NAV: (NavItem | DisabledNavItem)[] = [ + { to: "/painel", label: "Dashboard", icon: Home }, + { to: null, label: "Meu Agente", icon: Bot, disabled: true }, + { to: null, label: "Skills", icon: Sparkles, disabled: true }, + { to: null, label: "Integrações", icon: Plug, disabled: true }, + { to: "/painel/faturamento", label: "Faturamento", icon: CreditCard }, + { to: "/painel/configuracoes", label: "Configurações", icon: Settings }, +]; + +function PainelLayout() { + const { user, loading } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const [mobileOpen, setMobileOpen] = useState(false); + + useEffect(() => { + if (!loading && !user) { + navigate({ to: "/login", search: { redirect: location.pathname } }); + } + }, [loading, user, navigate, location.pathname]); + + if (loading || !user) { + return ( +
+
+
+ ); + } + + return ( + +
+ + +
+
+
+ + + + + + + + + setMobileOpen(false)} /> + + +
+
+ + +
+ +
+
+ +
+
+
+
+
+ ); +} + +function DesktopSidebar() { + return ( + + ); +} + +function SidebarNav({ onNavigate }: { onNavigate?: () => void } = {}) { + const location = useLocation(); + return ( + + ); +} + +function UserMenu() { + const { user } = useAuth(); + const { data: profile } = useProfile(); + const navigate = useNavigate(); + + const initials = (profile?.full_name || user?.email || "U") + .split(" ") + .map((s) => s[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + + const signOut = async () => { + await supabase.auth.signOut(); + navigate({ to: "/" }); + }; + + return ( +
+ + + + + + + +
{profile?.full_name || "Usuário"}
+
{user?.email}
+
+ + + + Perfil + + + + Sair + +
+
+
+ ); +} diff --git a/src/routes/recuperar-senha.tsx b/src/routes/recuperar-senha.tsx new file mode 100644 index 0000000..6acfe58 --- /dev/null +++ b/src/routes/recuperar-senha.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; +import { AuthCard } from "@/components/mika/AuthCard"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const schema = z.object({ + email: z.string().email("Formato de e-mail inválido."), +}); +type FormValues = z.infer; + +export const Route = createFileRoute("/recuperar-senha")({ + component: ForgotPasswordPage, +}); + +function ForgotPasswordPage() { + const [submitting, setSubmitting] = useState(false); + const [sent, setSent] = useState(false); + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormValues) => { + setSubmitting(true); + const { error } = await supabase.auth.resetPasswordForEmail(data.email, { + redirectTo: `${window.location.origin}/redefinir-senha`, + }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + setSent(true); + toast.success("Se houver uma conta com esse e-mail, enviamos um link de recuperação."); + }; + + return ( + + ← Voltar para o login + + } + > + {sent ? ( +
+

+ Se o e-mail informado existir em nossa base, você receberá o link em alguns minutos. + Verifique também a pasta de spam. +

+ +
+ ) : ( +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ )} +
+ ); +} diff --git a/src/routes/redefinir-senha.tsx b/src/routes/redefinir-senha.tsx new file mode 100644 index 0000000..67c1c94 --- /dev/null +++ b/src/routes/redefinir-senha.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; +import { passwordStrength } from "@/lib/password"; +import { AuthCard } from "@/components/mika/AuthCard"; +import { PasswordStrengthMeter } from "@/components/mika/PasswordStrengthMeter"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const schema = z + .object({ + password: z + .string() + .min(8, "A senha precisa de pelo menos 8 caracteres, 1 maiúscula e 1 número.") + .refine((v) => /[A-Z]/.test(v), "A senha precisa de pelo menos 1 maiúscula.") + .refine((v) => /\d/.test(v), "A senha precisa de pelo menos 1 número."), + confirm: z.string(), + }) + .refine((d) => d.password === d.confirm, { + path: ["confirm"], + message: "As senhas não coincidem.", + }); +type FormValues = z.infer; + +export const Route = createFileRoute("/redefinir-senha")({ + component: ResetPasswordPage, +}); + +function ResetPasswordPage() { + const navigate = useNavigate(); + const [submitting, setSubmitting] = useState(false); + const { register, handleSubmit, watch, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }); + const password = watch("password") || ""; + + const onSubmit = async (data: FormValues) => { + if (passwordStrength(data.password) < 2) { + toast.error("Escolha uma senha mais forte."); + return; + } + setSubmitting(true); + const { error } = await supabase.auth.updateUser({ password: data.password }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + toast.success("Senha redefinida com sucesso. Faça login."); + await supabase.auth.signOut(); + navigate({ to: "/login" }); + }; + + return ( + +
+
+ + + {errors.password &&

{errors.password.message}

} + +
+ +
+ + + {errors.confirm &&

{errors.confirm.message}

} +
+ + +
+
+ ); +} diff --git a/src/routes/signup.tsx b/src/routes/signup.tsx index 848ccdd..6536f40 100644 --- a/src/routes/signup.tsx +++ b/src/routes/signup.tsx @@ -1,38 +1,215 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { Logo } from "@/components/mika/Logo"; +"use client"; + +import { useState } from "react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Loader2, MailCheck } from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { translateAuthError } from "@/lib/auth-errors"; +import { passwordStrength } from "@/lib/password"; +import { AuthCard } from "@/components/mika/AuthCard"; +import { GoogleButton } from "@/components/mika/GoogleButton"; +import { PasswordStrengthMeter } from "@/components/mika/PasswordStrengthMeter"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; type SignupSearch = { plan?: string; cycle?: "monthly" | "yearly"; }; +const schema = z.object({ + full_name: z.string().min(2, "Informe seu nome completo.").max(120), + email: z.string().email("Formato de e-mail inválido."), + password: z + .string() + .min(8, "A senha precisa de pelo menos 8 caracteres, 1 maiúscula e 1 número.") + .refine((v) => /[A-Z]/.test(v), "A senha precisa de pelo menos 1 maiúscula.") + .refine((v) => /\d/.test(v), "A senha precisa de pelo menos 1 número."), + accept_terms: z.literal(true, { message: "Você precisa aceitar os termos." }), +}); +type FormValues = z.infer; + export const Route = createFileRoute("/signup")({ validateSearch: (search: Record): SignupSearch => ({ plan: typeof search.plan === "string" ? search.plan : undefined, cycle: search.cycle === "yearly" || search.cycle === "monthly" ? search.cycle : undefined, }), - component: SignupPlaceholder, + component: SignupPage, }); -function SignupPlaceholder() { +function SignupPage() { const { plan, cycle } = Route.useSearch(); - return ( -
-
- -

Criar conta

-

- Cadastro completo será implementado na Etapa 2. -

- {plan && ( -

- Plano selecionado: {plan} · {cycle === "yearly" ? "anual" : "mensal"} + const navigate = useNavigate(); + const [submitting, setSubmitting] = useState(false); + const [needsConfirm, setNeedsConfirm] = useState(null); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { accept_terms: false as unknown as true }, + }); + + const password = watch("password") || ""; + + const onSubmit = async (data: FormValues) => { + if (passwordStrength(data.password) < 2) { + toast.error("Escolha uma senha mais forte."); + return; + } + setSubmitting(true); + const { data: signupData, error } = await supabase.auth.signUp({ + email: data.email, + password: data.password, + options: { + data: { full_name: data.full_name }, + emailRedirectTo: `${window.location.origin}/painel`, + }, + }); + setSubmitting(false); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + // Sessão presente = e-mail já confirmado (auto-confirm desligado, então normalmente null) + if (signupData.session) { + toast.success("Conta criada!"); + if (plan && plan !== "enterprise") { + // TODO Etapa 3: chamar create-checkout-session com plan/cycle + navigate({ to: "/painel" }); + } else { + navigate({ to: "/painel" }); + } + return; + } + setNeedsConfirm(data.email); + }; + + const resend = async () => { + if (!needsConfirm) return; + const { error } = await supabase.auth.resend({ type: "signup", email: needsConfirm }); + if (error) { + toast.error(translateAuthError(error.message)); + return; + } + toast.success("E-mail reenviado. Confira sua caixa de entrada."); + }; + + if (needsConfirm) { + return ( + +

+
+ +
+

+ Clique no link do e-mail para ativar sua conta. Não esqueça de checar a pasta de spam.

- )} -
- ← Voltar para o início + + + Voltar para o login +
+ + ); + } + + return ( + + Já tem conta?{" "} + + Entrar + + + } + > +
+
+ + + {errors.full_name &&

{errors.full_name.message}

} +
+ +
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + + {errors.password &&

{errors.password.message}

} + +
+ +
+ + setValue("accept_terms", v === true ? true : (false as unknown as true), { + shouldValidate: true, + }) + } + /> + +
+ {errors.accept_terms && ( +

{errors.accept_terms.message}

+ )} + + +
+ +
+
+ ou +
-
+ + + ); } diff --git a/supabase/migrations/20260417170038_befdbd7a-ab3b-44a1-89d7-e7aead3d791d.sql b/supabase/migrations/20260417170038_befdbd7a-ab3b-44a1-89d7-e7aead3d791d.sql new file mode 100644 index 0000000..41fb2a2 --- /dev/null +++ b/supabase/migrations/20260417170038_befdbd7a-ab3b-44a1-89d7-e7aead3d791d.sql @@ -0,0 +1,210 @@ + +-- ============================================ +-- HELPER: trigger para updated_at +-- ============================================ +CREATE OR REPLACE FUNCTION public.update_updated_at_column() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +-- ============================================ +-- PROFILES +-- ============================================ +CREATE TABLE public.profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + full_name TEXT NOT NULL DEFAULT '', + company_name TEXT, + cpf_cnpj TEXT, + phone TEXT, + avatar_url TEXT, + stripe_customer_id TEXT UNIQUE, + onboarding_completed BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_profiles_stripe_customer_id ON public.profiles(stripe_customer_id); + +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Usuários veem o próprio perfil" + ON public.profiles FOR SELECT + USING (auth.uid() = id); + +CREATE POLICY "Usuários atualizam o próprio perfil" + ON public.profiles FOR UPDATE + USING (auth.uid() = id); + +CREATE POLICY "Usuários inserem o próprio perfil" + ON public.profiles FOR INSERT + WITH CHECK (auth.uid() = id); + +CREATE TRIGGER update_profiles_updated_at + BEFORE UPDATE ON public.profiles + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- Trigger: cria profile automaticamente no signup +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + INSERT INTO public.profiles (id, full_name) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'full_name', '') + ); + RETURN NEW; +END; +$$; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- ============================================ +-- PLANS +-- ============================================ +CREATE TABLE public.plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + price_monthly_brl NUMERIC(10,2), + price_yearly_brl NUMERIC(10,2), + stripe_price_id_monthly TEXT, + stripe_price_id_yearly TEXT, + features JSONB NOT NULL DEFAULT '[]'::jsonb, + highlighted BOOLEAN NOT NULL DEFAULT false, + is_enterprise BOOLEAN NOT NULL DEFAULT false, + display_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Planos são públicos" + ON public.plans FOR SELECT + USING (true); + +INSERT INTO public.plans (slug, name, description, price_monthly_brl, price_yearly_brl, features, highlighted, is_enterprise, display_order) VALUES +('basic', 'Basic', 'Para quem está começando a explorar IA pessoal', 69.90, 671.04, + '["Memória persistente básica","Até 500 mensagens/mês","Integração com Telegram","Suporte por e-mail"]'::jsonb, + false, false, 1), +('starter', 'Starter', 'Para profissionais que querem produtividade real', 199.90, 1919.04, + '["Memória persistente avançada","Até 3.000 mensagens/mês","Integração Google Workspace","Skills personalizadas (até 10)","Suporte prioritário"]'::jsonb, + false, false, 2), +('professional', 'Professional', 'Para quem usa IA o dia inteiro', 399.90, 3839.04, + '["Memória persistente ilimitada","Mensagens ilimitadas","Skills ilimitadas","Agendamentos automáticos","Integrações premium","Suporte dedicado em horário comercial"]'::jsonb, + true, false, 3), +('enterprise', 'Enterprise', 'Para times e empresas', NULL, NULL, + '["Tudo do Professional","Múltiplos usuários","SSO e auditoria","SLA garantido","Onboarding personalizado","Gerente de conta dedicado"]'::jsonb, + false, true, 4); + +-- ============================================ +-- SUBSCRIPTIONS +-- ============================================ +CREATE TABLE public.subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + plan_id UUID REFERENCES public.plans(id), + stripe_subscription_id TEXT UNIQUE, + status TEXT NOT NULL CHECK (status IN ('active','trialing','past_due','canceled','incomplete','incomplete_expired','unpaid')), + billing_cycle TEXT NOT NULL CHECK (billing_cycle IN ('monthly','yearly')), + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + cancel_at_period_end BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id); +CREATE INDEX idx_subscriptions_stripe_subscription_id ON public.subscriptions(stripe_subscription_id); +CREATE INDEX idx_subscriptions_status ON public.subscriptions(status); + +ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Usuários veem a própria assinatura" + ON public.subscriptions FOR SELECT + USING (auth.uid() = user_id); + +CREATE TRIGGER update_subscriptions_updated_at + BEFORE UPDATE ON public.subscriptions + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================ +-- AGENT INSTANCES +-- ============================================ +CREATE TABLE public.agent_instances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID UNIQUE NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'provisioning' CHECK (status IN ('provisioning','active','suspended','error')), + vps_host TEXT, + container_name TEXT, + telegram_bot_token_vault_id UUID, + telegram_bot_username TEXT, + uuid_tenant UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_agent_instances_user_id ON public.agent_instances(user_id); +CREATE INDEX idx_agent_instances_uuid_tenant ON public.agent_instances(uuid_tenant); +CREATE INDEX idx_agent_instances_status ON public.agent_instances(status); + +ALTER TABLE public.agent_instances ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Usuários veem o próprio agente" + ON public.agent_instances FOR SELECT + USING (auth.uid() = user_id); + +CREATE TRIGGER update_agent_instances_updated_at + BEFORE UPDATE ON public.agent_instances + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================ +-- STRIPE WEBHOOK EVENTS (idempotência) +-- ============================================ +CREATE TABLE public.stripe_webhook_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stripe_event_id TEXT UNIQUE NOT NULL, + event_type TEXT NOT NULL, + processed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + payload JSONB +); + +CREATE INDEX idx_stripe_webhook_events_event_id ON public.stripe_webhook_events(stripe_event_id); +CREATE INDEX idx_stripe_webhook_events_event_type ON public.stripe_webhook_events(event_type); + +ALTER TABLE public.stripe_webhook_events ENABLE ROW LEVEL SECURITY; +-- Sem policies: apenas service_role acessa + +-- ============================================ +-- ENTERPRISE LEADS +-- ============================================ +CREATE TABLE public.enterprise_leads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_name TEXT NOT NULL, + contact_name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT, + team_size TEXT NOT NULL CHECK (team_size IN ('1-10','11-50','51-200','200+')), + message TEXT, + status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','contacted','qualified','converted','lost')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE public.enterprise_leads ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Qualquer pessoa pode enviar lead" + ON public.enterprise_leads FOR INSERT + WITH CHECK (true); +-- Leitura: apenas service_role diff --git a/supabase/migrations/20260417170055_029831ee-4fe4-41ae-bf04-f7875b5fdf31.sql b/supabase/migrations/20260417170055_029831ee-4fe4-41ae-bf04-f7875b5fdf31.sql new file mode 100644 index 0000000..4239d35 --- /dev/null +++ b/supabase/migrations/20260417170055_029831ee-4fe4-41ae-bf04-f7875b5fdf31.sql @@ -0,0 +1,15 @@ + +DROP POLICY IF EXISTS "Qualquer pessoa pode enviar lead" ON public.enterprise_leads; + +CREATE POLICY "Visitantes podem enviar lead" + ON public.enterprise_leads FOR INSERT + TO anon, authenticated + WITH CHECK ( + char_length(company_name) BETWEEN 1 AND 200 + AND char_length(contact_name) BETWEEN 1 AND 200 + AND char_length(email) BETWEEN 3 AND 320 + AND email ~* '^[^@\s]+@[^@\s]+\.[^@\s]+$' + AND team_size IN ('1-10','11-50','51-200','200+') + AND (message IS NULL OR char_length(message) <= 2000) + AND status = 'new' + );