commit 0741b4e03c17923ec5608ccf9943770ba43e45e0 Author: Marcio Bevervanso Date: Tue Feb 17 17:49:42 2026 -0300 feat: complete dashboard redesign, payment history, and backend limits diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba133bf --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1ieKyFsxWOnR3ACt_oJ5Hpidgm9Wny9At + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/foodsnap-main.rar b/foodsnap-main.rar new file mode 100644 index 0000000..33df37b Binary files /dev/null and b/foodsnap-main.rar differ diff --git a/foodsnap.rar b/foodsnap.rar new file mode 100644 index 0000000..33deea0 Binary files /dev/null and b/foodsnap.rar differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..e44b7c3 --- /dev/null +++ b/index.html @@ -0,0 +1,143 @@ + + + + + + + + FoodSnap - Nutritional Intelligence + + + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..97fde74 --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "FoodSnap", + "description": "Instant nutritional analysis from a simple photo.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ba8f55b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3286 @@ +{ + "name": "foodsnap", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "foodsnap", + "version": "0.0.0", + "dependencies": { + "@google/genai": "^1.33.0", + "@supabase/supabase-js": "2.39.7", + "framer-motion": "11.0.8", + "html2pdf.js": "^0.12.1", + "lucide-react": "0.344.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT", + "optional": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.34.0.tgz", + "integrity": "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/functions-js": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.1.5.tgz", + "integrity": "sha512-BNzC5XhCzzCaggJ8s53DP+WeHHGT/NfTsx2wUSSGKR2/ikLFQTBCDzMvGz/PxYMqRko/LwncQtKXGOYp1PkPaw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/gotrue-js": { + "version": "2.62.2", + "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.62.2.tgz", + "integrity": "sha512-AP6e6W9rQXFTEJ7sTTNYQrNf0LCcnt1hUW+RIgUK+Uh3jbWvcIST7wAlYyNZiMlS9+PYyymWQ+Ykz/rOYSO0+A==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.9.2.tgz", + "integrity": "sha512-I6yHo8CC9cxhOo6DouDMy9uOfW7hjdsnCxZiaJuIVZm1dBGTFiQPgfMa9zXCamEWzNyWRjZvupAUuX+tqcl5Sw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.9.3.tgz", + "integrity": "sha512-lAp50s2n3FhGJFq+wTSXLNIDPw5Y0Wxrgt44eM5nLSA3jZNUUP3Oq2Ccd1CbZdVntPCWLZvJaU//pAd2NE+QnQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.14.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.5.5.tgz", + "integrity": "sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.39.7", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.39.7.tgz", + "integrity": "sha512-1vxsX10Uhc2b+Dv9pRjBjHfqmw2N2h1PyTg9LEfICR3x2xwE24By1MGCjDZuzDKH5OeHCsf4it6K8KRluAAEXA==", + "license": "MIT", + "dependencies": { + "@supabase/functions-js": "2.1.5", + "@supabase/gotrue-js": "2.62.2", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.9.2", + "@supabase/realtime-js": "2.9.3", + "@supabase/storage-js": "2.5.5" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "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/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "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/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "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", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/framer-motion": { + "version": "11.0.8", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.0.8.tgz", + "integrity": "sha512-1KSGNuqe1qZkS/SWQlDnqK2VCVzRVEoval379j0FiUBJAZoqgwyvqFkfvJbgW2IPFo4wX16K+M0k5jO23lCIjA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "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", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/html2pdf.js": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.12.1.tgz", + "integrity": "sha512-3rBWQ96H5oOU9jtoz3MnE/epGi27ig9h8aonBk4JTpvUERM3lMRxhIRckhJZEi4wE0YfRINoYOIDY0hLY0CHgQ==", + "license": "MIT", + "dependencies": { + "html2canvas": "^1.0.0", + "jspdf": "^3.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jspdf": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", + "integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "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", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..505e02c --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "foodsnap", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@google/genai": "^1.33.0", + "@supabase/supabase-js": "2.39.7", + "framer-motion": "11.0.8", + "html2pdf.js": "^0.12.1", + "lucide-react": "0.344.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..4927a8f --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,327 @@ +import React, { useState, useEffect } from 'react'; +import Header from './components/landing/Header'; +import Hero from './components/landing/Hero'; +import CoachHighlight from './components/landing/CoachHighlight'; +import HowItWorks from './components/landing/HowItWorks'; +import Features from './components/landing/Features'; +import Testimonials from './components/landing/Testimonials'; +import Pricing from './components/landing/Pricing'; +import FAQ from './components/landing/FAQ'; +import Footer from './components/landing/Footer'; +import RegistrationModal from './components/modals/RegistrationModal'; +import CalculatorsModal from './components/modals/CalculatorsModal'; +import Dashboard from './pages/Dashboard'; +import AdminPanel from './pages/AdminPanel'; +import ProfessionalDashboard from './pages/ProfessionalDashboard'; +import FAQPage from './pages/FAQPage'; +import { LanguageProvider } from './contexts/LanguageContext'; +import { supabase } from './lib/supabase'; +import { Loader2 } from 'lucide-react'; + +import { User } from './types'; + +// removed User interface definition + +const AppContent: React.FC = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isToolsOpen, setIsToolsOpen] = useState(false); + const [authMode, setAuthMode] = useState<'login' | 'register'>('register'); + const [selectedPlan, setSelectedPlan] = useState('starter'); + const [currentView, setCurrentView] = useState<'home' | 'faq'>('home'); // Estado de navegação + const [isCompletingProfile, setIsCompletingProfile] = useState(false); // Novo estado para controle de perfil incompleto + + const [user, setUser] = useState(null); + const [isAdminView, setIsAdminView] = useState(false); + const [isProfessionalView, setIsProfessionalView] = useState(false); + const [isLoadingSession, setIsLoadingSession] = useState(true); + + // Check active session on load + // Check active session on load + useEffect(() => { + let mounted = true; + + const initializeAuth = async () => { + try { + // Obter sessão inicial sem race conditions complexas + const { data: { session }, error } = await supabase.auth.getSession(); + + if (error) { + console.error("Erro ao obter sessão inicial:", error); + if (mounted) setIsLoadingSession(false); + return; + } + + if (session?.user) { + console.log("App: Sessão encontrada, carregando perfil..."); + if (mounted) { + await fetchUserProfile(session.user.id, session.user.email); + } + } else { + console.log("App: Nenhuma sessão ativa."); + if (mounted) setIsLoadingSession(false); + } + } catch (err) { + console.error("Erro inesperado na autenticação:", err); + if (mounted) setIsLoadingSession(false); + } + }; + + initializeAuth(); + + const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { + console.log(`Auth event: ${event}`); + if (!mounted) return; + + if (event === 'SIGNED_OUT') { + setUser(null); + setIsAdminView(false); + setIsProfessionalView(false); + setIsLoadingSession(false); + setCurrentView('home'); + setIsCompletingProfile(false); + } else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') { + if (session?.user) { + // Apenas recarrega se o usuário ainda não estiver setado ou se mudou + // Mas para garantir atualização de claims/perfil, recarregamos. + await fetchUserProfile(session.user.id, session.user.email); + } + } + }); + + return () => { + mounted = false; + subscription.unsubscribe(); + }; + }, []); + + const fetchUserProfile = async (userId: string, email?: string) => { + try { + let profile = null; + + // Tentativa única de buscar perfil. O cliente Supabase já trata retries de rede. + const { data, error } = await supabase + .from('profiles') + .select('*') + .eq('id', userId) + .maybeSingle(); + + if (error) throw error; + + profile = data; + + // Se não tem perfil ou falta telefone, solicitamos completar cadastro + if (!profile || !profile.phone_e164) { + console.warn("Perfil incompleto. Solicitando dados."); + // Não fazemos signOut, apenas abrimos o modal para completar + setIsCompletingProfile(true); + setAuthMode('register'); // Visualmente irrelevante pois isCompletingProfile domina + setIsModalOpen(true); + // User fica null, então cai na Landing Page com Modal aberto. Perfeito. + return; + } + + // Se perfil ok, garante que flag de completar está false + setIsCompletingProfile(false); + + const { data: entitlement } = await supabase + .from('user_entitlements') + .select('*') + .eq('user_id', userId) + .maybeSingle(); + + let plan: 'free' | 'pro' | 'trial' = 'free'; + if (entitlement) { + if (entitlement.entitlement_code === 'pro' && entitlement.is_active) plan = 'pro'; + else if (entitlement.entitlement_code === 'trial' && entitlement.is_active) plan = 'trial'; + } + + setUser({ + id: userId, + name: profile.full_name || 'Usuário', + email: email || profile.email || '', + phone: profile.phone_e164, + public_id: profile.public_id, + avatar: undefined, // Column does not exist in DB, using undefined to trigger UI fallback + is_admin: profile.is_admin, + is_professional: profile.is_professional, + plan: plan, + plan_valid_until: entitlement?.valid_until + }); + + // Auto-switch logic: + // If user is professional, default to Professional View. + // If user is Admin, they usually see Admin View but we might default to user dashboard for them unless they toggle. + // Logic requested: "System verifies and logs him in correct panel". + + const loginIntent = localStorage.getItem('login_intent'); + + if (profile.is_professional) { + setIsProfessionalView(true); + } else { + setIsProfessionalView(false); + } + + // Override if explicit intent was set (though we removed the button, old intents might linger, safe to ignore or keep) + if (loginIntent === 'user') { + // If they explicitly wanted user view but are pro, maybe respect it? + // For now, let's stick to the requested "System verifies" rule above. + } + + } catch (error) { + console.error('Error fetching profile:', error); + setUser(null); + } finally { + setIsLoadingSession(false); + } + + }; + + const handleOpenRegister = (plan: string = 'starter') => { + setSelectedPlan(plan); + setAuthMode('register'); + setIsModalOpen(true); + setIsCompletingProfile(false); + }; + + const handleOpenLogin = (context?: 'user' | 'professional') => { + // If context is professional, we can store this intent to redirect after login + // For now, we'll use a simple localStorage flag or state + if (context === 'professional') { + localStorage.setItem('login_intent', 'professional'); + } else { + localStorage.setItem('login_intent', 'user'); + } + + setAuthMode('login'); + setIsModalOpen(true); + setIsCompletingProfile(false); + }; + + const handleAuthSuccess = async () => { + setIsModalOpen(false); + setIsCompletingProfile(false); + // Force refresh profile to ensure we have latest data + const { data: { session } } = await supabase.auth.getSession(); + if (session?.user) { + await fetchUserProfile(session.user.id, session.user.email); + + // Check intent + const intent = localStorage.getItem('login_intent'); + if (intent === 'professional') { + // We can't know for sure if they are pro yet inside this function scope easily unless we use the user state which might be stale + // But fetchUserProfile updates 'user'. + // Ideally we wait for user state to update. + // For simplicity, let's rely on the useEffect that watches 'user' or just check 'isProfessionalView' toggle inside fetchUserProfile? + // Lets keep it simple: We just set the view if the profile allows it. + // Actually, fetchUserProfile runs, updates User. + // We can check localstorage IN fetchUserProfile. + } + localStorage.removeItem('login_intent'); + } + }; + + const handleLogout = async () => { + await supabase.auth.signOut(); + setUser(null); + setIsAdminView(false); + }; + + const toggleAdminView = () => { + if (user?.is_admin) { + setIsAdminView(!isAdminView); + } + }; + + // Helper function for navigating + const handleNavigate = (view: 'home' | 'faq') => { + setCurrentView(view); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + if (isLoadingSession) { + return ( +
+ +
+ ); + } + + // Rota Admin + if (user && isAdminView && user.is_admin) { + return ; + } + + // Rota Profissional + if (user && isProfessionalView) { + return setIsProfessionalView(false)} onLogout={handleLogout} />; + } + + // Rota Dashboard Usuário + if (user) { + return ( + setIsProfessionalView(true)} + /> + ); + } + + // Rota Pública (Landing Page ou FAQ Page) + return ( +
+
handleOpenRegister('starter')} + onLogin={handleOpenLogin} + onOpenTools={() => setIsToolsOpen(true)} + onNavigate={handleNavigate} // Passa navegação + /> + +
+ {currentView === 'home' ? ( + <> + handleOpenRegister('starter')} /> + handleOpenRegister('starter')} /> + + + + + + + ) : ( + handleNavigate('home')} /> + )} +
+ +
handleOpenRegister('starter')} + onNavigate={handleNavigate} // Passa navegação + /> + + setIsModalOpen(false)} + plan={selectedPlan} + mode={authMode} + isCompletingProfile={isCompletingProfile} // Passa o estado de completar perfil + onSuccess={handleAuthSuccess} + /> + + setIsToolsOpen(false)} + /> +
+ ); +}; + +const App: React.FC = () => { + return ( + + + + ); +}; + +export default App; \ No newline at end of file diff --git a/src/components/coach/AnalysisSection.tsx b/src/components/coach/AnalysisSection.tsx new file mode 100644 index 0000000..b8c6ff8 --- /dev/null +++ b/src/components/coach/AnalysisSection.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Activity, Trophy, CheckCircle2, AlertCircle } from 'lucide-react'; +import { Card, Badge } from './Shared'; + +interface AnalysisSectionProps { + analysis: any; +} + +const AnalysisSection: React.FC = ({ analysis }) => { + return ( +
+ }> +
+
+

Gordura Estimada

+

{analysis?.body_fat_percentage}%

+
+
+

Massa Muscular

+

{analysis?.muscle_mass_level}

+
+
+
+

Avaliação Postural

+

+ {analysis?.posture_analysis || "Nenhum desvio significativo detectado."} +

+
+
+ + }> +
+
+

+ Pontos Fortes +

+
+ {analysis?.strengths?.map((s: string, i: number) => ( + + ))} +
+
+
+

+ Foco Total +

+
+ {analysis?.weaknesses?.map((s: string, i: number) => ( + + ))} +
+
+
+
+
+ ); +}; + +export default AnalysisSection; diff --git a/src/components/coach/CoachResult.tsx b/src/components/coach/CoachResult.tsx new file mode 100644 index 0000000..59598a2 --- /dev/null +++ b/src/components/coach/CoachResult.tsx @@ -0,0 +1,255 @@ +import React, { useState } from 'react'; +import { Dumbbell, Utensils, Activity, Loader2 } from 'lucide-react'; +import { motion } from 'framer-motion'; + +import { KPI, Tab } from './Shared'; +import AnalysisSection from './AnalysisSection'; +import DietSection from './DietSection'; +import WorkoutSection from './WorkoutSection'; + +// PDF pages +import { PdfAnalysisCompact } from './pdf/PdfAnalysisCompact'; +import { PdfDietCompact } from './pdf/PdfDietCompact'; +import { PdfWorkoutCompact } from './pdf/PdfWorkoutCompact'; + +// @ts-ignore +import { renderToStaticMarkup } from 'react-dom/server'; + +interface CoachResultProps { + data: any; + onReset: () => void; +} + +const N8N_WEBHOOK_URL = 'https://n8n.seureview.com.br/webhook/pdf-coach'; + +const CoachResult: React.FC = ({ data, onReset }) => { + const [activeTab, setActiveTab] = useState<'analysis' | 'diet' | 'workout'>('analysis'); + const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); + + if (!data) return null; + + const { analysis, diet, workout, motivation_quote } = data; + + const handleSavePDF = async () => { + setIsGeneratingPdf(true); + + try { + // 1) Render 2 pages (Diet & Workout only - requested by user) + const pdfPages = ( +
+ {/* REMOVED ANALYSIS PAGE AS REQUESTED */} + +
+ +
+ +
+ +
+
+ ); + + const pagesHtml = renderToStaticMarkup(pdfPages); + + // 2) Full HTML + print-lock CSS (Optimized for Gotenberg) + const fullHtml = ` + + + + + + + + + + + + + ${pagesHtml} + +`; + + // 3) Send to n8n + const fileName = `FoodSnap_Titan_${new Date().toISOString().split('T')[0]}`; + + const response = await fetch(N8N_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ html: fullHtml, file_name: fileName }), + }); + + if (!response.ok) throw new Error(`Erro n8n: ${response.status} ${response.statusText}`); + + // 4) Download PDF + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${fileName}.pdf`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (err) { + console.error('PDF Generation Server Error:', err); + alert('Erro ao gerar PDF no servidor. Verifique se o Webhook do n8n está configurado.'); + } finally { + setIsGeneratingPdf(false); + } + }; + + return ( +
+ {/* Premium Header */} +
+
+
+ +
+
+
+ + Protocolo Titan + + {new Date().toLocaleDateString()} +
+ +

+ Seu Blueprint
+ + De Transformação + +

+ +

+ "{motivation_quote || 'Disciplina é a ponte entre metas e conquistas.'}" +

+
+ +
+ + + +
+
+ +
+ + + + +
+
+ + {/* Tabs */} +
+
+ setActiveTab('analysis')} + icon={} + label="Diagnóstico" + /> + setActiveTab('diet')} + icon={} + label="Nutrição" + /> + setActiveTab('workout')} + icon={} + label="Treinamento" + /> +
+
+ + {/* Rich UI */} +
+ {activeTab === 'analysis' && ( + + + + )} + + {activeTab === 'diet' && ( + + + + )} + + {activeTab === 'workout' && ( + + + + )} +
+
+ ); +}; + +export default CoachResult; diff --git a/src/components/coach/CoachWizard.tsx b/src/components/coach/CoachWizard.tsx new file mode 100644 index 0000000..8f5245c --- /dev/null +++ b/src/components/coach/CoachWizard.tsx @@ -0,0 +1,405 @@ + +import React, { useState, useRef, useEffect } from 'react'; +import { Camera, Upload, X, ChevronRight, Check, AlertCircle, Loader2, Dumbbell, Apple, Activity, Image as ImageIcon } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { supabase } from '@/lib/supabase'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface CoachWizardProps { + isOpen: boolean; + onClose: () => void; + onComplete: (data: any) => void; +} + +type Step = 'photos' | 'goal' | 'processing'; + +const CoachWizard: React.FC = ({ isOpen, onClose, onComplete }) => { + const { t } = useLanguage(); + const [step, setStep] = useState('photos'); + const [photos, setPhotos] = useState<{ front?: string, side?: string, back?: string }>({}); + const [goal, setGoal] = useState(''); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [loadingMsgIndex, setLoadingMsgIndex] = useState(0); + + const loadingMessages = t.coach.processing.steps; + + useEffect(() => { + let interval: any; + if (step === 'processing' && !errorMessage) { + interval = setInterval(() => { + setLoadingMsgIndex(prev => (prev + 1) % loadingMessages.length); + }, 3000); + } + return () => clearInterval(interval); + }, [step, errorMessage]); + + // Refs for different input types + const fileInputRef = useRef(null); + const cameraInputRef = useRef(null); + const [activePhotoField, setActivePhotoField] = useState<'front' | 'side' | 'back' | null>(null); + + if (!isOpen) return null; + + // --- Image Processing Helper (Resize & Compress) --- + const processImage = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const img = new Image(); + img.src = event.target?.result as string; + img.onload = () => { + const canvas = document.createElement('canvas'); + const MAX_WIDTH = 1024; // Resize to max 1024px width for AI/Backend limit + const scaleSize = MAX_WIDTH / img.width; + const width = (scaleSize < 1) ? MAX_WIDTH : img.width; + const height = (scaleSize < 1) ? img.height * scaleSize : img.height; + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0, width, height); + + // Compress to JPEG 0.7 quality + const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.7); + resolve(compressedDataUrl); + }; + img.onerror = (err) => reject(err); + }; + reader.onerror = (err) => reject(err); + }); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0] && activePhotoField) { + const file = e.target.files[0]; + try { + setLoading(true); // Show momentary loading for compression + const compressedImage = await processImage(file); + setPhotos(prev => ({ ...prev, [activePhotoField]: compressedImage })); + setActivePhotoField(null); + } catch (error) { + console.error("Error processing image:", error); + alert("Erro ao processar a imagem. Tente outra."); + } finally { + setLoading(false); + // Reset inputs to allow selecting same file again if needed + if (fileInputRef.current) fileInputRef.current.value = ""; + if (cameraInputRef.current) cameraInputRef.current.value = ""; + } + } + }; + + const triggerUpload = (field: 'front' | 'side' | 'back', source: 'gallery' | 'camera') => { + setActivePhotoField(field); + if (source === 'gallery') { + setTimeout(() => fileInputRef.current?.click(), 0); + } else { + setTimeout(() => cameraInputRef.current?.click(), 0); + } + }; + + const handleNext = () => { + if (step === 'photos') { + if (photos.front && photos.side && photos.back) setStep('goal'); + else alert("Por favor, adicione as 3 fotos (Frente, Perfil, Costas) para garantir a precisão da análise."); + } else if (step === 'goal') { + if (goal) startProcessing(); + } + }; + + const startProcessing = async () => { + setStep('processing'); + setLoading(true); + setErrorMessage(null); + + + try { + // Create a timeout promise that rejects after 55 seconds + const timeoutPromise = new Promise((_, reject) => { + const id = setTimeout(() => { + clearTimeout(id); + reject(new Error(t.coach.processing.wait)); + }, 55000); // 55s strict timeout + }); + + // Race between the API call and the timeout + const response: any = await Promise.race([ + supabase.functions.invoke('coach-generator', { + body: { photos, goal, intent: 'coach' } + }), + timeoutPromise + ]); + + // If we get here, it means the API responded before timeout + const { data, error } = response; + + + if (error) { + console.error("Supabase Invoke Error:", error); + // Tenta extrair a mensagem de erro real do backend se existir + let errorMsg = "Falha na comunicação com a IA."; + if (error && typeof error === 'object') { + // Supabase functions usually return { context: ..., error: { message: "..." } } or just error + if ('message' in error) errorMsg = (error as any).message; + else errorMsg = JSON.stringify(error); + } + throw new Error(errorMsg); + } + + if (!data) { + throw new Error("Nenhuma resposta recebida da IA."); + } + + console.log("Coach Result:", data); + + // Validate essential data presence + if (!data.analysis || !data.diet || !data.workout) { + throw new Error("A resposta da IA veio incompleta. Tente com fotos mais claras."); + } + + onComplete(data); + onClose(); + + } catch (err: any) { + console.error("Coach Logic Error:", err); + + let message = "Erro ao gerar protocolo. Verifique sua conexão e tente novamente."; + if (err.name === 'AbortError') message = "O servidor demorou muito para responder. Tente fotos menores."; + if (err.message) message = err.message; + + setErrorMessage(message); + // Don't auto-close, let user see error and retry + } finally { + setLoading(false); + // If error, stay on processing step or go back? + // Better to show error on processing screen with a "Retry" button or "Back" + } + }; + + return ( +
+ + {/* Header */} +
+
+

+ + {t.coach.title} +

+

{t.coach.subtitle}

+
+ +
+ + {/* Content */} +
+ + + {step === 'photos' && ( + +
+ +

+ {t.coach.photosStep.alert.split(':')[0]}: {t.coach.photosStep.alert.split(':')[1]} +

+
+ +
+ {['front', 'side', 'back'].map((side) => ( +
+

+ {side === 'front' ? t.coach.photosStep.front : side === 'side' ? t.coach.photosStep.side : t.coach.photosStep.back} +

+
+ {photos[side as keyof typeof photos] ? ( + <> + +
+ + +
+
+ +
+ + ) : ( +
+ +
+ + +
+ {/* Helper text removed as buttons are visible */} +
+ )} +
+
+ ))} +
+
+ )} + + {step === 'goal' && ( + +

{t.coach.goalStep.title}

+ +
+ {[ + { id: 'hypertrophy', icon: , title: t.coach.goalStep.hypertrophy.title, desc: t.coach.goalStep.hypertrophy.desc }, + { id: 'definition', icon: , title: t.coach.goalStep.definition.title, desc: t.coach.goalStep.definition.desc }, + { id: 'maintenance', icon: , title: t.coach.goalStep.maintenance.title, desc: t.coach.goalStep.maintenance.desc }, + { id: 'strength', icon: , title: t.coach.goalStep.strength.title, desc: t.coach.goalStep.strength.desc } + ].map((opt) => ( + + ))} +
+
+ )} + + {step === 'processing' && ( + + {errorMessage ? ( +
+
+ +
+

{t.coach.processing.errorTitle}

+

{errorMessage}

+ +
+ ) : ( + <> +
+
+
+ +
+

+ {loadingMessages[loadingMsgIndex]} +

+

+ {t.coach.processing.wait} +

+ + )} +
+ )} +
+ +
+ + {/* Footer */} + {step !== 'processing' && ( +
+ {step === 'goal' && ( + + )} + +
+ )} +
+ + {/* Hidden Inputs */} + + +
+ ); +}; + +export default CoachWizard; diff --git a/src/components/coach/DietSection.tsx b/src/components/coach/DietSection.tsx new file mode 100644 index 0000000..ac94980 --- /dev/null +++ b/src/components/coach/DietSection.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Plus, Trash2, Edit2, CheckCircle2, AlertCircle, Droplets, Apple, Clock, Pill } from 'lucide-react'; +import { MacroCard } from './Shared'; + +interface DietSectionProps { + diet: any; +} + +const DietSection: React.FC = ({ diet }) => { + return ( +
+ {/* Macros & Hydration */} +
+ + + +
+
+ + {diet?.hydration_liters}L + Água/Dia +
+
+ +
+ {/* Meal Plan List */} +
+

Plano Alimentar

+
+ {diet?.meal_plan_example?.map((meal: any, i: number) => ( +
+
+
+ {i + 1} +
+
+

{meal.name}

+ {meal.time_range && ( +

+ {meal.time_range} +

+ )} +
+
+ +
+ {/* New Format: Multiple Options */} + {meal.options && Array.isArray(meal.options) ? ( +
+ {meal.options.map((opt: string, idx: number) => ( +
+

{opt}

+
+ ))} +
+ ) : ( + // Legacy Fallback +
+

+ {meal.main_option || (meal.options && meal.options[0]) || "Opção Padrão"} +

+
+ )} + + {/* Substitution Suggestion */} + {(meal.substitution_suggestion || meal.substitution) && ( +
+
+ +
+
+

Dica de Substituição

+

+ {meal.substitution_suggestion || meal.substitution} +

+
+
+ )} +
+
+ ))} +
+
+ + {/* Supplements */} +
+

Suplementação

+
+
+
+ {diet?.supplements?.map((sup: any, i: number) => { + // Handle complex object or simple string + const name = typeof sup === 'string' ? sup : sup.name; + const dosage = typeof sup === 'string' ? '' : sup.dosage; + const reason = typeof sup === 'string' ? '' : sup.reason; + + return ( +
+
+ {name} +
+ {dosage &&

{dosage}

} + {reason &&

{reason}

} +
+ ); + })} +
+
+
+
+
+ ); +}; + +export default DietSection; diff --git a/src/components/coach/Shared.tsx b/src/components/coach/Shared.tsx new file mode 100644 index 0000000..d2efa9f --- /dev/null +++ b/src/components/coach/Shared.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +export const KPI = ({ label, value }: any) => ( +
+

{label}

+

{value || '-'}

+
+); + +export const Tab = ({ active, onClick, icon, label }: any) => ( + +); + +export const Card = ({ title, icon, children, className = "" }: any) => ( +
+
+
+ {icon} +
+

{title}

+
+ {children} +
+); + +export const Badge = ({ text, color }: any) => { + const styles = color === 'green' + ? 'bg-emerald-50 text-emerald-700 border-emerald-100' + : 'bg-orange-50 text-orange-700 border-orange-100'; + + return ( + + {text} + + ); +}; + +export const MacroCard = ({ label, value, color }: any) => { + const colors: any = { + brand: 'bg-brand-50 text-brand-900 border-brand-100', + blue: 'bg-blue-50 text-blue-900 border-blue-100', + yellow: 'bg-yellow-50 text-yellow-900 border-yellow-100' + }; + + return ( +
+ {label} + {value} +
+ ); +}; diff --git a/src/components/coach/WorkoutSection.tsx b/src/components/coach/WorkoutSection.tsx new file mode 100644 index 0000000..7654081 --- /dev/null +++ b/src/components/coach/WorkoutSection.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { Calendar, Activity, ChevronUp, ChevronDown, Dumbbell } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface WorkoutSectionProps { + workout: any; +} + +const WorkoutSection: React.FC = ({ workout }) => { + const [openInjury, setOpenInjury] = useState(false); + + return ( +
+ {/* Workout Header */} +
+
+
+

Estrutura de Treino

+

{workout?.split}

+

{workout?.frequency_days} dias na semana

+
+
+ +
+
+ + {/* Injury Adaptations Accordion - Only show if data exists */} + {workout?.injury_adaptations && ( +
setOpenInjury(!openInjury)}> +
+

+ Adaptações para Dores? +

+ {openInjury ? : } +
+

Clique para ver exercícios alternativos.

+ + + {openInjury && ( + +
+ {Object.entries(workout.injury_adaptations).map(([key, val]: any) => ( +
+ + {key === 'knee_pain' ? 'Dor no Joelho' : key === 'shoulder_pain' ? 'Dor no Ombro' : 'Dor nas Costas'} + +

{val}

+
+ ))} +
+
+ )} +
+
+ )} +
+ + {/* Routine Grid */} +
+ {workout?.routine?.map((day: any, i: number) => ( +
+
+
+ + {day.day} + +

{day.muscle_group}

+
+
+ +
+
+ +
+ {day.exercises?.map((ex: any, idx: number) => ( +
+
+

{ex.name}

+
+ {ex.sets}x + {ex.reps} +
+
+ {ex.technique &&

{ex.technique}

} +
+ ))} +
+
+ ))} +
+
+ ); +}; + +export default WorkoutSection; diff --git a/src/components/coach/pdf/PdfAnalysisCompact.tsx b/src/components/coach/pdf/PdfAnalysisCompact.tsx new file mode 100644 index 0000000..10c3f14 --- /dev/null +++ b/src/components/coach/pdf/PdfAnalysisCompact.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Activity } from 'lucide-react'; +import { PdfHeaderRow, safeStr, asArray } from './PdfShared'; + +export const PdfAnalysisCompact: React.FC<{ data: any }> = ({ data }) => { + const a = data?.analysis || {}; + const d = data?.diet || {}; + const w = data?.workout || {}; + + const bullets = asArray( + a?.improvements || + a?.what_to_improve || + a?.improve || + a?.recommendations || + a?.tips || + a?.notes || + a?.observations || + [] + ) + .map((x: any) => (typeof x === 'string' ? x : safeStr(x?.text, ''))) + .filter(Boolean) + .slice(0, 8); + + const positives = asArray(a?.strengths || a?.positives || a?.good_points || a?.pontos_fortes || []) + .map((x: any) => (typeof x === 'string' ? x : safeStr(x?.text, ''))) + .filter(Boolean) + .slice(0, 6); + + return ( +
+ } + /> + +
+
+
Biótipo
+
{safeStr(a?.somatotype)}
+
+
+
Objetivo
+
{safeStr(w?.focus)}
+
+
+
Calorias
+
+ {Math.round(d?.total_calories || 0)} kcal +
+
+
+
Estrutura
+
{safeStr(w?.split)}
+
+
+ +
+
+
Pontos fortes
+ {positives.length ? ( +
    + {positives.map((t: string, i: number) => ( +
  • {t}
  • + ))} +
+ ) : ( +

+ {safeStr(a?.summary || a?.overview || a?.diagnosis || a?.diagnostico, 'Sem detalhes extras.')} +

+ )} +
+ +
+
O que melhorar
+ {bullets.length ? ( +
    + {bullets.map((t: string, i: number) => ( +
  • {t}
  • + ))} +
+ ) : ( +

+ {safeStr(a?.improvement_summary || a?.next_steps || a?.proximos_passos, 'Sem detalhes extras.')} +

+ )} +
+
+ +
+
Notas rápidas
+

+ {safeStr( + a?.final_note || a?.note || a?.observacao_final || a?.closing, + 'Consistência diária > perfeição. Foque em execução e acompanhamento.' + )} +

+
+
+ ); +}; diff --git a/src/components/coach/pdf/PdfDietCompact.tsx b/src/components/coach/pdf/PdfDietCompact.tsx new file mode 100644 index 0000000..ac124d4 --- /dev/null +++ b/src/components/coach/pdf/PdfDietCompact.tsx @@ -0,0 +1,181 @@ +import React, { useMemo } from 'react'; +import { Utensils, Droplets, Pill } from 'lucide-react'; +import { PdfHeaderRow, safeStr, asArray } from './PdfShared'; + +function truncate(text: string, max = 140) { + const t = (text || '').trim(); + if (!t) return '-'; + return t.length > max ? t.slice(0, max - 1) + '…' : t; +} + +function pickMeals(diet: any): any[] { + // ✅ match do frontend + if (Array.isArray(diet?.meal_plan_example) && diet.meal_plan_example.length) return diet.meal_plan_example; + + // fallback antigos + const candidates = [ + diet?.meals, + diet?.meal_plan, + diet?.plan, + diet?.daily_plan, + diet?.diet_plan, + diet?.meals_plan, + diet?.mealsPlan, + diet?.refeicoes, + diet?.refeicoes_plano, + ]; + for (const c of candidates) if (Array.isArray(c) && c.length) return c; + + return []; +} + +export const PdfDietCompact: React.FC<{ diet: any }> = ({ diet }) => { + const meals = useMemo(() => pickMeals(diet).slice(0, 6), [diet]); // 6 max pra caber 1 página + const supplements = asArray(diet?.supplements).slice(0, 6); + + const protein = diet?.macros?.protein_g ?? diet?.protein_g ?? diet?.protein ?? diet?.protein_grams; + const carbs = diet?.macros?.carbs_g ?? diet?.carbs_g ?? diet?.carbs ?? diet?.carb_grams; + const fats = diet?.macros?.fats_g ?? diet?.fat_g ?? diet?.fat ?? diet?.fat_grams; + const water = diet?.hydration_liters ?? diet?.water_liters ?? diet?.hydration; + + return ( +
+ } + /> + + {/* Summary row */} +
+
+
+
Calorias/dia
+
{Math.round(diet?.total_calories || 0)} kcal
+
+ +
+
Proteína
+
{safeStr(protein)}
+
+ +
+
Carbo
+
{safeStr(carbs)}
+
+ +
+
+
Gordura
+
{safeStr(fats)}
+
+
+ +
{safeStr(water, '-')}{String(water || '').includes('L') ? '' : 'L'}
+
+
+
+
+ + {/* Main grid: meals + supplements */} +
+ {/* Meals (2 cols) */} +
+
Plano Alimentar
+ +
+ {meals.length ? ( + meals.map((meal: any, i: number) => { + const options = Array.isArray(meal?.options) ? meal.options : []; + const opt1 = options?.[0] || meal?.main_option || ''; + const opt2 = options?.[1] || ''; + + return ( +
+
+
+
+ {safeStr(meal?.name, `Refeição ${i + 1}`)} +
+ {meal?.time_range && ( +
+ {safeStr(meal.time_range)} +
+ )} +
+
#{i + 1}
+
+ +
+ {opt1 ? ( +
+ Opção 1: + {truncate(String(opt1), 160)} +
+ ) : null} + + {opt2 ? ( +
+ Opção 2: + {truncate(String(opt2), 160)} +
+ ) : null} + + {(meal?.substitution_suggestion || meal?.substitution) ? ( +
+ Dica de substituição:{' '} + {truncate(String(meal?.substitution_suggestion || meal?.substitution), 180)} +
+ ) : null} +
+
+ ); + }) + ) : ( +
+ Não achei diet.meal_plan_example. Se teu JSON mudou, me manda 1 exemplo do diet. +
+ )} +
+
+ + {/* Supplements (1 col) */} +
+
Suplementação
+ +
+
+ {supplements.length ? ( + supplements.map((sup: any, i: number) => { + const name = typeof sup === 'string' ? sup : sup?.name; + const dosage = typeof sup === 'string' ? '' : sup?.dosage; + const reason = typeof sup === 'string' ? '' : sup?.reason; + + return ( +
+
+ +
{truncate(String(name || 'Suplemento'), 40)}
+
+ {dosage ?
{truncate(String(dosage), 60)}
: null} + {reason ?
{truncate(String(reason), 80)}
: null} +
+ ); + }) + ) : ( +
+ Sem suplementos informados. +
+ )} +
+
+ +
+ Dica: água + consistência diária. Ajustes finos semanais. +
+
+
+
+ ); +}; diff --git a/src/components/coach/pdf/PdfShared.tsx b/src/components/coach/pdf/PdfShared.tsx new file mode 100644 index 0000000..a646dda --- /dev/null +++ b/src/components/coach/pdf/PdfShared.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +export function safeStr(v: any, fallback = '-'): string { + if (v === null || v === undefined) return fallback; + if (typeof v === 'string') return v.trim() || fallback; + if (typeof v === 'number') return Number.isFinite(v) ? String(v) : fallback; + return fallback; +} + +export function asArray(x: any): any[] { + return Array.isArray(x) ? x : []; +} + +export const PdfHeaderRow: React.FC<{ + index: string; + title: string; + subtitle: string; + icon: React.ReactNode; +}> = ({ index, title, subtitle, icon }) => { + return ( +
+
+
+ Protocolo Titan • FoodSnap Coach +
+

+ {index}. {title} +

+

{subtitle}

+
+
{icon}
+
+ ); +}; diff --git a/src/components/coach/pdf/PdfWorkoutCompact.tsx b/src/components/coach/pdf/PdfWorkoutCompact.tsx new file mode 100644 index 0000000..334ec3b --- /dev/null +++ b/src/components/coach/pdf/PdfWorkoutCompact.tsx @@ -0,0 +1,119 @@ +import React, { useMemo } from 'react'; +import { Dumbbell, Quote } from 'lucide-react'; +import { PdfHeaderRow, safeStr, asArray } from './PdfShared'; + +function pickRoutine(workout: any) { + // ✅ SHAPE REAL DO FRONTEND (WorkoutSection usa workout.routine) + const r = workout?.routine ?? workout?.days ?? workout?.plan ?? []; + return Array.isArray(r) ? r : []; +} + +function pickExercises(day: any) { + const ex = day?.exercises ?? day?.items ?? day?.workout ?? []; + return Array.isArray(ex) ? ex : []; +} + +function exLine(ex: any) { + if (typeof ex === 'string') return ex; + + const name = safeStr(ex?.name || ex?.exercise || ex?.movimento, ''); + const sets = ex?.sets ?? ex?.series; + const reps = ex?.reps ?? ex?.repetitions; + const technique = safeStr(ex?.technique || ex?.notes || ex?.cue, ''); + + const sr: string[] = []; + if (sets !== undefined && sets !== null && String(sets).trim() !== '') sr.push(`${sets}x`); + if (reps !== undefined && reps !== null && String(reps).trim() !== '') sr.push(`${reps}`); + + const left = [name, sr.length ? sr.join(' ') : ''].filter(Boolean).join(' — '); + return [left, technique].filter(Boolean).join(' • ') || '-'; +} + +export const PdfWorkoutCompact: React.FC<{ workout: any; quote?: string }> = ({ workout, quote }) => { + const days = useMemo(() => pickRoutine(workout).slice(0, 5), [workout]); + + return ( +
+ } + /> + + {/* Top summary */} +
+
+
+
Split
+
{safeStr(workout?.split)}
+
+
+
Frequência
+
{safeStr(workout?.frequency_days, '-')} dias
+
+
+
Objetivo
+
{safeStr(workout?.focus)}
+
+
+
Duração
+
{safeStr(workout?.duration || '4–8 semanas')}
+
+
+
+ + {/* Day cards (muito mais bonito que tabela) */} +
+ {days.length ? ( + days.map((day: any, idx: number) => { + const exs = pickExercises(day).slice(0, 5); + const dayName = safeStr(day?.day || day?.name || day?.title || `Dia ${idx + 1}`, `Dia ${idx + 1}`); + const muscle = safeStr(day?.muscle_group || day?.focus || day?.grupo, ''); + + return ( +
+
+
+
{dayName}
+
{muscle}
+
+
{safeStr(workout?.split, '')}
+
+ +
+ {exs.length ? ( +
    + {exs.map((ex: any, i: number) => ( +
  • + {exLine(ex)} +
  • + ))} +
+ ) : ( +
Treino do dia não detalhado.
+ )} +
+ + {day?.technique_focus ? ( +
+ Técnica: {safeStr(day?.technique_focus, '-')} +
+ ) : null} +
+ ); + }) + ) : ( +
+ Rotina não detalhada neste relatório. +
+ )} +
+ +
+ + "{quote || 'Disciplina é a ponte entre metas e conquistas.'}" +
+
+ ); +}; diff --git a/src/components/common/HistoryCard.tsx b/src/components/common/HistoryCard.tsx new file mode 100644 index 0000000..e732576 --- /dev/null +++ b/src/components/common/HistoryCard.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import MacroBadge from './MacroBadge'; + +interface HistoryCardProps { + item: { + id: string; + img: string; + category: string; + details?: string; + cals: number; + score: number; + date: string; + protein: string; + carbs: string; + fat: string; + }; + fallback: string; +} + +const HistoryCard: React.FC = ({ item, fallback }) => ( +
+
+ {item.category} { + const target = e.currentTarget; + // Proteção contra Loop Infinito de Erros + if (target.src !== fallback) { + target.src = fallback; + } + }} + /> +
+
+ {item.cals} kcal +
+ {item.score > 0 && ( +
= 80 ? 'bg-green-500' : 'bg-yellow-500'}`}> + Score {item.score} +
+ )} +
+
+
{item.category}
+ {item.details &&

{item.details}

} + +
+ {item.date} +
+ +
+
+ Prot + {item.protein} +
+
+ Carb + {item.carbs} +
+
+
+
+); + +export default HistoryCard; diff --git a/src/components/common/MacroBadge.tsx b/src/components/common/MacroBadge.tsx new file mode 100644 index 0000000..75c56e5 --- /dev/null +++ b/src/components/common/MacroBadge.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +interface MacroBadgeProps { + label: string; + value: string | number; + color: string; +} + +const MacroBadge: React.FC = ({ label, value, color }) => ( +
+ {label}: + {value} +
+); + +export default MacroBadge; diff --git a/src/components/common/StatCard.tsx b/src/components/common/StatCard.tsx new file mode 100644 index 0000000..d75ce39 --- /dev/null +++ b/src/components/common/StatCard.tsx @@ -0,0 +1,27 @@ +import React, { ReactNode } from 'react'; + +interface StatCardProps { + title: string; + value: string; + sub: string; + icon: ReactNode; + highlight?: boolean; +} + +const StatCard: React.FC = ({ title, value, sub, icon, highlight }) => ( +
+
+
+ {icon} +
+ {highlight && } +
+
+

{value}

+

{title}

+

{sub}

+
+
+); + +export default StatCard; diff --git a/src/components/dashboard/DashboardCoach.tsx b/src/components/dashboard/DashboardCoach.tsx new file mode 100644 index 0000000..d29e3d1 --- /dev/null +++ b/src/components/dashboard/DashboardCoach.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { Sparkles, Zap, ScanLine, ScanEye, BrainCircuit, TrendingUp, History, Plus, Activity } from 'lucide-react'; +import CoachResult from '@/components/coach/CoachResult'; + +interface DashboardCoachProps { + coachPlan: any; + setCoachPlan: (plan: any) => void; + coachHistory?: any[]; // Array of coach_analyses records + setIsCoachWizardOpen: (open: boolean) => void; +} + +const DashboardCoach: React.FC = ({ coachPlan, setCoachPlan, coachHistory = [], setIsCoachWizardOpen }) => { + + // ───────────────────────────────────────────────────────────────────────────── + // STATE 1: NO HISTORY (HERO / ONBOARDING) + // ───────────────────────────────────────────────────────────────────────────── + if (!coachHistory || coachHistory.length === 0) { + return ( +
+ + + {/* Sleek Modern Header */} +
+ {/* Abstract Premium Background */} +
+
+ +
+
+
+ + AI Personal Trainer +
+ +

+ Seu Corpo,
+ + Sua Melhor Versão. + +

+

+ Chega de treinos genéricos. Nossa IA analisa seu biótipo e cria um protocolo 100% científico e adaptado para você. +

+ +
+ +
+
+ + {/* Visual Stats / Tech Feel */} +
+
+
+
+ +
+ {/* Fake Data Lines */} +
+ + +
+
+
+
+
+
+
+
+ ACCURACY + 98.5% +
+
+
+
+
+
+ + {/* Features Grid - Darker/Cleaner */} +
+ {[ + { title: 'Visão Computacional', desc: 'Identifica gordura e desvios posturais.', icon: }, + { title: 'Hiper-Personalização', desc: 'Cada grama de carbo calculada para VOCÊ.', icon: }, + { title: 'Evolução Constante', desc: 'Refaça a análise a cada 30 dias.', icon: }, + ].map((feat, i) => ( +
+
+ {feat.icon} +
+

{feat.title}

+

{feat.desc}

+
+ ))} +
+ + {/* Social Proof / Trust Strip */} +
+ {[ + { label: "Protocolos Gerados", value: "10k+" }, + { label: "Precisão da IA", value: "98%" }, + { label: "Tempo Médio", value: "30 seg" }, + { label: "Avaliação", value: "4.9/5" }, + ].map((stat, i) => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+ +
+ ); + } + + // ───────────────────────────────────────────────────────────────────────────── + // STATE 2: COACH RESULT (CONTENT ONLY, HISTORY IS IN MAIN SIDEBAR) + // ───────────────────────────────────────────────────────────────────────────── + return ( +
+ {coachPlan ? ( + setCoachPlan(null)} /> + ) : ( +
+
+ +
+

Selecione uma análise

+

+ Escolha um protocolo no menu lateral ("Coach AI → Histórico") ou gere um novo. +

+ +
+ )} +
+ ); +}; + +export default DashboardCoach; diff --git a/src/components/dashboard/DashboardHistory.tsx b/src/components/dashboard/DashboardHistory.tsx new file mode 100644 index 0000000..9aa925a --- /dev/null +++ b/src/components/dashboard/DashboardHistory.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Search, Loader2 } from 'lucide-react'; +import HistoryCard from '@/components/common/HistoryCard'; +import MacroBadge from '@/components/common/MacroBadge'; + +interface DashboardHistoryProps { + history: any[]; + loadingHistory: boolean; + t: any; + fallbackImage: string; +} + +const DashboardHistory: React.FC = ({ history, loadingHistory, t, fallbackImage }) => { + return ( +
+
+

{t.dashboard.historyTitle}

+

{t.dashboard.historySubtitle}

+
+ +
+
+ + +
+
+ + {loadingHistory ? ( +
+ ) : history.length === 0 ? ( +
+

{t.dashboard.emptyHistory}

+
+ ) : ( +
+ {history.map(item => ( +
+
+ {item.category} { + const target = e.currentTarget; + if (target.src !== fallbackImage) { + target.src = fallbackImage; + } + }} + className="w-full h-full object-cover" + /> + {item.score > 0 && ( +
= 80 ? 'bg-green-500' : (item.score >= 50 ? 'bg-yellow-500' : 'bg-red-500')}`}> + {item.score} +
+ )} +
+ +
+
+
+

{item.category}

+ {item.details &&

{item.details}

} +
+ {item.date} +
+ +
+ + + + +
+
+
+ ))} +
+ )} +
+ ); +}; + +export default DashboardHistory; diff --git a/src/components/dashboard/DashboardOverview.tsx b/src/components/dashboard/DashboardOverview.tsx new file mode 100644 index 0000000..41037f7 --- /dev/null +++ b/src/components/dashboard/DashboardOverview.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { Search, Zap, CreditCard, MessageCircle, Smartphone, QrCode, ChevronRight, Loader2, CheckCircle2, TrendingUp, Calendar, ArrowRight } from 'lucide-react'; +import HistoryCard from '@/components/common/HistoryCard'; + +interface DashboardOverviewProps { + user: { + name: string; + public_id: string; + plan: string; + plan_valid_until?: string; + }; + stats: { + totalCount: number; + avgCals: number; + }; + loadingStats: boolean; + history: any[]; + loadingHistory: boolean; + planName: string; + t: any; + whatsappUrl: string; + qrCodeUrl: string; + whatsappNumber: string; + setActiveTab: (tab: string) => void; + fallbackImage: string; +} + +const DashboardOverview: React.FC = ({ + user, + stats, + loadingStats, + history, + loadingHistory, + planName, + t, + whatsappUrl, + qrCodeUrl, + whatsappNumber, + setActiveTab, + fallbackImage +}) => { + return ( +
+ + {/* 1. Hero Section (Glassmorphism / Dark Mode Concept) */} +
+ {/* Background Blobs */} +
+
+ +
+
+
+ {planName} Member +
+

+ Olá, {user.name.split(' ')[0]} 👋 +

+

+ Vamos transformar sua saúde hoje? Acompanhe seu progresso e mantenha o foco nas suas metas. +

+ +
+ + +
+
+ + {/* Quick Stats in Hero */} +
+
+

{t.dashboard.statDishes}

+

{loadingStats ? '...' : stats.totalCount}

+
+ +12% vs mês +
+
+
+

{t.dashboard.statCals}

+

{loadingStats ? '...' : Math.round(stats.avgCals)}

+
+ Média Diária +
+
+
+
+
+ +
+ {/* 2. Recent History (Main Column) */} +
+
+

+ + {t.dashboard.recentTitle} +

+ +
+ + {loadingHistory ? ( +
+ ) : history.length === 0 ? ( +
+
+ +
+

{t.dashboard.emptyRecent}

+ +
+ ) : ( +
+ {history.slice(0, 4).map(item => ( + + ))} +
+ )} +
+ + {/* 3. Side Widget (WhatsApp Connect) */} +
+
+
+ +
+
+ QR Code +
+

Conectar WhatsApp

+

Escaneie para enviar fotos e receber análises instantâneas.

+ + +
+
+ + {/* Mini Plan Status */} +
+
+

Status do Plano

+
+
+ {planName} +
+ {user.plan_valid_until && ( +

Válido até {new Date(user.plan_valid_until).toLocaleDateString('pt-BR')}

+ )} +
+
+ +
+
+
+
+
+ ); +}; + +export default DashboardOverview; diff --git a/src/components/dashboard/DashboardSubscription.tsx b/src/components/dashboard/DashboardSubscription.tsx new file mode 100644 index 0000000..4b7bac0 --- /dev/null +++ b/src/components/dashboard/DashboardSubscription.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState } from 'react'; +import { CreditCard, ExternalLink, Calendar, CheckCircle2, History, AlertCircle, Loader2 } from 'lucide-react'; +import { User } from '@/types'; +import { supabase } from '@/lib/supabase'; + +interface DashboardSubscriptionProps { + user: User; + planName: string; + t: any; + handleStripePortal: () => void; +} + +const DashboardSubscription: React.FC = ({ user, planName, t, handleStripePortal }) => { + const [payments, setPayments] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchPayments = async () => { + try { + const { data, error } = await supabase + .from('payments') + .select('*') + .order('created_at', { ascending: false }); + + if (data) setPayments(data); + } catch (error) { + console.error("Error fetching payments:", error); + } finally { + setLoading(false); + } + }; + + if (user.id) { + fetchPayments(); + } + }, [user.id]); + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': return 'bg-green-100 text-green-700'; + case 'pending': return 'bg-yellow-100 text-yellow-700'; + case 'failed': return 'bg-red-100 text-red-700'; + default: return 'bg-gray-100 text-gray-700'; + } + }; + + return ( +
+
+

{t.dashboard.subTitle}

+

{t.dashboard.subDesc}

+
+ + {/* Current Plan Card */} +
+
+ +
+ +
+
+
+
+ {t.dashboard.currentPlan} + {(user.plan === 'pro' || user.plan === 'trial') && ( + + Ativo + + )} +
+

+ {planName} +

+
+
+ + + {user.plan_valid_until + ? `${t.dashboard.validUntil} ${new Date(user.plan_valid_until).toLocaleDateString('pt-BR')}` + : t.dashboard.limitedAccess} + +
+
+
+
+
+ +
+

+ Gerencie sua assinatura e métodos de pagamento através do portal seguro. +

+ +
+
+ + {/* Payment History */} +
+
+

+ + Histórico de Pagamentos +

+
+ + {loading ? ( +
+ +
+ ) : payments.length === 0 ? ( +
+ +

Nenhum pagamento registrado ainda.

+
+ ) : ( +
+ + + + + + + + + + + {payments.map((payment) => ( + + + + + + + ))} + +
DataValorPlanoStatus
+ {new Date(payment.created_at).toLocaleDateString('pt-BR')} + + R$ {payment.amount.toFixed(2)} + + {payment.plan_type} + + + {payment.status === 'completed' ? 'Pago' : payment.status} + +
+
+ )} +
+ + {/* Upgrade Banner (Conditional) */} + {user.plan === 'free' && ( +
+
+
+

+ + Desbloqueie todo o potencial +

+

+ Obtenha análises ilimitadas, histórico completo e acesso às funcionalidades profissionais. +

+
+ +
+ {/* Decorative BG */} +
+
+ )} +
+ ); +}; + +export default DashboardSubscription; diff --git a/src/components/landing/CoachHighlight.tsx b/src/components/landing/CoachHighlight.tsx new file mode 100644 index 0000000..ef846ec --- /dev/null +++ b/src/components/landing/CoachHighlight.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { ScanEye, Dumbbell, Utensils, CheckCircle2 } from 'lucide-react'; + +interface CoachHighlightProps { + onRegister: () => void; +} + +const CoachHighlight: React.FC = ({ onRegister }) => { + return ( +
+ + {/* Background Effects */} +
+
+
+
+ +
+
+ + {/* Left: Text Content */} +
+
+ + Nova Tecnologia +
+ +

+ Seu corpo analisado
+ + pela Inteligência Artificial. + +

+ +

+ Esqueça planilhas genéricas. Nossa IA escaneia seu biótipo através de fotos e cria, em segundos, o protocolo exato de treino e dieta para sua estrutura. +

+ +
+ } title="Visão Computacional" desc="Identifica massa muscular, gordura e postura." /> + } title="Dieta Milimétrica" desc="Macros calculados para o seu metabolismo basal." /> + } title="Treino Adaptativo" desc="Periodização baseada no seu nível e objetivo." /> +
+ + +
+ + {/* Right: Visual Demo (Mockup) */} +
+
+ Trainer reviewing data + + {/* Floating Elements duplicating the 'Scanner' feel */} +
+
+ +
+
+
Scanning...
+
Ectomorfo Identificado
+
+
+ +
+
+ + Protocolo Gerado +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+ ); +}; + +const FeatureRow = ({ icon, title, desc }: any) => ( +
+
+ {icon} +
+
+

{title}

+

{desc}

+
+
+); + +export default CoachHighlight; diff --git a/src/components/landing/FAQ.tsx b/src/components/landing/FAQ.tsx new file mode 100644 index 0000000..2567806 --- /dev/null +++ b/src/components/landing/FAQ.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +const FAQ: React.FC = () => { + const { t } = useLanguage(); + + const faqs = [ + { question: t.faq.q1, answer: t.faq.a1 }, + { question: t.faq.q2, answer: t.faq.a2 }, + { question: t.faq.q3, answer: t.faq.a3 }, + { question: t.faq.q4, answer: t.faq.a4 } + ]; + + const [openIndex, setOpenIndex] = useState(null); + + return ( +
+
+
+

{t.faq.title}

+
+ +
+ {faqs.map((faq, index) => ( +
+ + +
+

+ {faq.answer} +

+
+
+ ))} +
+
+
+ ); +}; + +export default FAQ; \ No newline at end of file diff --git a/src/components/landing/Features.tsx b/src/components/landing/Features.tsx new file mode 100644 index 0000000..bf6b67a --- /dev/null +++ b/src/components/landing/Features.tsx @@ -0,0 +1,213 @@ +import React from 'react'; +import { Flame, Scale, MessageSquare, Sparkles, ArrowLeftRight, UtensilsCrossed, CheckCircle2, Dumbbell } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { useLanguage } from '@/contexts/LanguageContext'; + +const Features: React.FC = () => { + const { t } = useLanguage(); + + const features = [ + { + icon: , + bg: "bg-emerald-50 border-emerald-100 ring-2 ring-emerald-500/10 shadow-lg shadow-emerald-500/10", + title: "Coach AI", + description: "NOVO! Seu personal trainer e nutricionista via IA. Envie fotos, descubra seu biótipo e receba treinos e dietas 100% adaptados.", + novelty: true + }, + { + icon: , + bg: "bg-red-50 border-red-100", + title: "Startup para Profissionais", + description: "Personal Trainer ou Nutricionista? Tenha seu próprio app/dashboard para gerenciar alunos, vender planos e acompanhar a evolução deles.", + novelty: true + }, + { + icon: , + bg: "bg-orange-50 border-orange-100", + title: t.features.f1Title, + description: t.features.f1Desc + }, + { + icon: , + bg: "bg-brand-50 border-brand-100", + title: t.features.f2Title, + description: t.features.f2Desc + }, + { + icon: , + bg: "bg-blue-50 border-blue-100", + title: t.features.f3Title, + description: t.features.f3Desc + }, + { + icon: , + bg: "bg-indigo-50 border-indigo-100", + title: t.features.f4Title, + description: t.features.f4Desc + }, + { + icon: , + bg: "bg-purple-50 border-purple-100", + title: t.features.f5Title, + description: t.features.f5Desc + } + ]; + + return ( +
+
+
+ +
+ + + + {t.features.guruTitle} + +

+ {t.features.mainTitle} +

+

+ {t.features.subtitle} +

+
+ +
+ {features.map((feature, index) => ( + +
+ {feature.icon} +
+
+

+ {feature.title} + {/* @ts-ignore */} + {feature.novelty && ( + Novo + )} +

+

{feature.description}

+
+
+ ))} +
+
+ + +
+ + {/* Abstract Background Blob */} +
+ + {/* Main Card Image Container */} +
+ Healthy Bowl + + {/* Gradient Overlay for Text Readability */} +
+
+ + {/* Floating UI Elements */} + + {/* Top Right: Score Badge */} +
+
+
+ 94 + SCORE +
+
+

Qualidade

+
+ + Excelente +
+
+
+
+ + {/* Bottom Left: Visual Tip (Chat Bubble style) */} +
+
+
+
+ +
+
+ {t.features.visualTipTitle} +

+ {t.features.visualTipDesc} +

+
+
+
+
+ + {/* Bottom Center: Macro Analysis Card (Simulating App UI) */} +
+
+
+
+

Salada Caesar & Frango

+

Análise em tempo real • 12:42

+
+
+ 340 kcal +
+
+ +
+
+
+ Proteína + 28g +
+
+
+
+
+
+
+ Carboidratos + 12g +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+ ); +}; + +export default Features; \ No newline at end of file diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx new file mode 100644 index 0000000..bd4e38b --- /dev/null +++ b/src/components/landing/Footer.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Scan, Zap, MessageCircle, Instagram, Twitter, Linkedin } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface FooterProps { + onRegister: () => void; + onNavigate?: (view: 'home' | 'faq') => void; // Optional prop to support navigation +} + +const Footer: React.FC = ({ onRegister, onNavigate }) => { + const { t } = useLanguage(); + + const handleFaqClick = (e: React.MouseEvent) => { + if (onNavigate) { + e.preventDefault(); + onNavigate('faq'); + } + }; + + const handleHomeClick = (e: React.MouseEvent, id?: string) => { + if (onNavigate) { + // Se tiver navegação, garante que estamos na home primeiro + if (!id) { + e.preventDefault(); + onNavigate('home'); + } + } + }; + + return ( + + ); +}; + +export default Footer; \ No newline at end of file diff --git a/src/components/landing/Header.tsx b/src/components/landing/Header.tsx new file mode 100644 index 0000000..c651cc5 --- /dev/null +++ b/src/components/landing/Header.tsx @@ -0,0 +1,218 @@ +import React, { useState, useEffect } from 'react'; +import { Scan, Menu, X, Zap, ArrowRight, Globe, Calculator } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface HeaderProps { + onRegister: () => void; + onLogin: (context?: 'user' | 'professional') => void; + onOpenTools: () => void; + onNavigate?: (view: 'home' | 'faq') => void; +} + +const Header: React.FC = ({ onRegister, onLogin, onOpenTools, onNavigate }) => { + const [isScrolled, setIsScrolled] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [langMenuOpen, setLangMenuOpen] = useState(false); + const { language, setLanguage, t } = useLanguage(); + + useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 20); + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const navLinks = [ + { name: t.header.howItWorks, id: 'how-it-works' }, + { name: t.header.features, id: 'features' }, + { name: t.header.pricing, id: 'pricing' }, + ]; + + const toggleLang = (lang: 'pt' | 'en' | 'es') => { + setLanguage(lang); + setLangMenuOpen(false); + }; + + const handleScrollTo = (id: string) => { + // Se a função de navegação for fornecida, garante que vamos para a home primeiro + if (onNavigate) { + onNavigate('home'); + // Pequeno delay para permitir a renderização da home antes de scrollar + setTimeout(() => { + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + }, 100); + } else { + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + } + setMobileMenuOpen(false); + }; + + const handleLogoClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (onNavigate) onNavigate('home'); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+
+ {/* Logo Professional */} + +
+ + +
+
+ + FoodSnap.ai + + + {t.header.slogan} + +
+
+ + {/* Desktop Nav */} + + + {/* Mobile Menu Toggle */} + +
+ + {/* Mobile Menu */} + {mobileMenuOpen && ( +
+ {navLinks.map((link) => ( + + ))} + + + +
+ + + +
+ +
+ + +
+
+ )} +
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/src/components/landing/Hero.tsx b/src/components/landing/Hero.tsx new file mode 100644 index 0000000..0f4c1d5 --- /dev/null +++ b/src/components/landing/Hero.tsx @@ -0,0 +1,360 @@ +import React, { useState, useRef } from 'react'; +import { ArrowRight, MessageCircle, Scan, Zap, Camera, Lightbulb, Sparkles, Upload, X } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface HeroProps { + onRegister: () => void; +} + +const Hero: React.FC = ({ onRegister }) => { + const [demoState, setDemoState] = useState<'initial' | 'analyzing' | 'result'>('initial'); + const [userImage, setUserImage] = useState(null); + const [showDemoInstruction, setShowDemoInstruction] = useState(false); + const fileInputRef = useRef(null); + const { t } = useLanguage(); + + const handleDemoClick = () => { + setShowDemoInstruction(true); + }; + + const handleTriggerUpload = () => { + fileInputRef.current?.click(); + }; + + const scrollToPricing = (e: React.MouseEvent) => { + e.preventDefault(); + const element = document.getElementById('pricing'); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const imageUrl = URL.createObjectURL(file); + setUserImage(imageUrl); + setShowDemoInstruction(false); // Fecha o modal + setDemoState('analyzing'); + + // Simulate network delay and processing + setTimeout(() => { + setDemoState('result'); + }, 3500); // Um pouco mais de tempo para ver o "robô pensando" + } + }; + + return ( +
+ {/* Hidden Input for Demo */} + + + {/* Modern Background */} +
+
+
+ +
+
+ + {/* Text Content */} + +
+ + + + + NOVO: Coach AI 2.0 - Treino & Dieta +
+ +

+ {t.hero.titleStart}
+ + {t.hero.titleHighlight} + +

+ +

+ {t.hero.subtitle} +

+ +
+ + +
+ +
+
+
+ {[1, 2, 3].map((i) => ( +
+ user +
+ ))} +
+ {t.hero.stats} +
+ +
+ + {t.hero.analysis} +
+
+
+ + {/* Visual Element - Modern Mockup Interactive */} + +
+ {/* Notch */} +
+ + {/* Screen */} +
+ {/* Header Mockup */} +
+
+
+ +
+
+

FoodSnap

+
+ +

Online

+
+
+
+
+ + {/* Chat Area */} +
+ {/* User Message (Image) */} + + +
+ Meal +
+

12:30

+
+
+ + {/* Loading State / Robot Message */} + {demoState === 'analyzing' && ( + +
+
+
+ + + +
+ {t.hero.demoProcessing} +
+
+
+ )} + + {/* AI Response */} + {(demoState === 'initial' || demoState === 'result') && ( + +
+ {/* Header Analysis */} +
+
+
+ +
+ {t.hero.analysis} +
+ + Score A + +
+ +
+ {/* Macros */} +
+
+ + {demoState === 'initial' ? '485' : '520'} kcal + + + {demoState === 'initial' ? 'High Protein' : 'Balanced'} + +
+ +
+
+

Prot

+

{demoState === 'initial' ? '32g' : '28g'}

+
+
+

Carb

+

{demoState === 'initial' ? '45g' : '55g'}

+
+
+

Gord

+

{demoState === 'initial' ? '12g' : '18g'}

+
+
+
+ + {/* Insights */} +
+
+ +

+ {t.hero.demoAdvice} {t.hero.demoAdviceText} +

+
+
+
+
+
+ +

Powered by FoodSnap

+
+
+ )} +
+ + {/* Input Area (Visual Only) */} +
+
+ +
+
+ ... +
+
+
+
+ + {/* Floating Elements */} + {demoState === 'initial' && ( +
+
+
+ +
+
+

Detected

+

Salmon Bowl

+
+
+
+ )} +
+ +
+
+ + {/* Demo Instruction Modal */} + + {showDemoInstruction && ( +
+ setShowDemoInstruction(false)} + className="absolute inset-0 bg-gray-950/70 backdrop-blur-sm" + /> + + + +
+ +
+ +

{t.hero.demoModalTitle}

+

+ {t.hero.demoModalDesc} +

+ + +
+
+ )} +
+
+ ); +}; + +export default Hero; \ No newline at end of file diff --git a/src/components/landing/HowItWorks.tsx b/src/components/landing/HowItWorks.tsx new file mode 100644 index 0000000..3c2e56b --- /dev/null +++ b/src/components/landing/HowItWorks.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Camera, Send, Activity, ChevronRight } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +const HowItWorks: React.FC = () => { + const { t } = useLanguage(); + + const steps = [ + { + icon: , + title: t.howItWorks.step1Title, + description: t.howItWorks.step1Desc + }, + { + icon: , + title: t.howItWorks.step2Title, + description: t.howItWorks.step2Desc + }, + { + icon: , + title: t.howItWorks.step3Title, + description: t.howItWorks.step3Desc + } + ]; + + return ( +
+ {/* Background Pattern */} +
+ +
+
+

{t.howItWorks.title}

+

+ {t.howItWorks.subtitle} +

+
+ +
+ + {steps.map((step, index) => ( +
+
+ {step.icon} +
+ + {/* Connector */} + {index < steps.length - 1 && ( +
+ +
+ )} + +

{step.title}

+

{step.description}

+
+ ))} +
+
+
+ ); +}; + +export default HowItWorks; \ No newline at end of file diff --git a/src/components/landing/Pricing.tsx b/src/components/landing/Pricing.tsx new file mode 100644 index 0000000..43d4ef1 --- /dev/null +++ b/src/components/landing/Pricing.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { Check, ShieldCheck, Sparkles, Star, Gift } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface PricingProps { + onRegister: (plan: string) => void; +} + +const Pricing: React.FC = ({ onRegister }) => { + const { t } = useLanguage(); + + return ( +
+ {/* Background Decor */} +
+ +
+
+

{t.pricing.title}

+

{t.pricing.subtitle}

+
+ + {/* Free Plan Banner */} +
+
+
+
+ +
+
+

{t.pricing.freeTierTitle}

+

{t.pricing.freeTierDesc}

+
+
+ +
+
+ +
+ + {/* Plan: Monthly (Was Starter in structure, now Monthly) */} +
+

{t.pricing.plans.monthly.title}

+
+ {t.pricing.plans.monthly.price} + {t.pricing.plans.monthly.period} +
+

{t.pricing.plans.monthly.description}

+

{t.pricing.plans.monthly.billingInfo}

+ +
    + {t.pricing.plans.monthly.features.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ + +
+ + {/* Plan: Annual (Highlighted) */} +
+
+
+
+

+ {t.pricing.plans.annual.title} + {t.pricing.plans.annual.savings} +

+

{t.pricing.plans.annual.description}

+
+
+ +
+
+ +
+ {t.pricing.plans.annual.price} + {t.pricing.plans.annual.period} +
+

{t.pricing.plans.annual.billingInfo}

+ +
+ +
    + {t.pricing.plans.annual.features.map((item, i) => ( +
  • +
    + +
    + {item} +
  • + ))} +
+ + +
+
+ + {/* Plan: Quarterly */} +
+

{t.pricing.plans.quarterly.title}

+
+ {t.pricing.plans.quarterly.price} + {t.pricing.plans.quarterly.period} +
+

{t.pricing.plans.quarterly.description}

+

{t.pricing.plans.quarterly.billingInfo}

+ +
    + {t.pricing.plans.quarterly.features.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ + +
+ +
+ + {/* --- PROFESSIONAL PLAN SECTION --- */} +
+
+ {/* Abstract Shapes */} +
+ +
+
+
+ Para Nutris e Personais +
+

Área Profissional

+

+ Sistema completo para gestão de alunos, treinos e dietas. +

+
+ +
+ + Em Breve + + +
+
+
+
+ +
+
+ ); +}; + +export default Pricing; \ No newline at end of file diff --git a/src/components/landing/Testimonials.tsx b/src/components/landing/Testimonials.tsx new file mode 100644 index 0000000..50e6e14 --- /dev/null +++ b/src/components/landing/Testimonials.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Star, Quote } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +const Testimonials: React.FC = () => { + const { t } = useLanguage(); + + const reviews = [ + { + name: "Rafael Silva", + role: t.testimonials.r1Role, + image: "https://picsum.photos/100/100?random=10", + content: t.testimonials.r1Content + }, + { + name: "Dra. Mariana Costa", + role: t.testimonials.r2Role, + image: "https://picsum.photos/100/100?random=11", + content: t.testimonials.r2Content + }, + { + name: "Lucas Mendes", + role: t.testimonials.r3Role, + image: "https://picsum.photos/100/100?random=12", + content: t.testimonials.r3Content + } + ]; + + return ( +
+
+
+

{t.testimonials.title}

+

{t.testimonials.subtitle}

+
+ +
+ {reviews.map((review, index) => ( +
+ + +
+ {[...Array(5)].map((_, i) => )} +
+ +

"{review.content}"

+ +
+ {review.name} +
+

{review.name}

+

{review.role}

+
+
+
+ ))} +
+
+
+ ); +}; + +export default Testimonials; \ No newline at end of file diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx new file mode 100644 index 0000000..b1254b5 --- /dev/null +++ b/src/components/layout/MobileNav.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { LayoutDashboard, History, CreditCard, Dumbbell } from 'lucide-react'; + +interface MobileNavProps { + activeTab: string; + setActiveTab: (tab: any) => void; + t: any; +} + +const MobileNav: React.FC = ({ activeTab, setActiveTab, t }) => { + return ( +
+ + + + +
+ ); +}; + +export default MobileNav; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..4ee5ff4 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react'; +import { LayoutDashboard, History, CreditCard, Dumbbell, ShieldAlert, BrainCircuit, LogOut, Zap, ChevronDown, ChevronRight, Calendar } from 'lucide-react'; +import { User } from '@/types'; + +interface SidebarProps { + user: User; + activeTab: string; + setActiveTab: (tab: string) => void; + onLogout: () => void; + onOpenAdmin?: () => void; + onOpenPro?: () => void; + t: any; // Translation object + coachHistory?: any[]; // Array of coach_analyses records + onSelectCoachPlan?: (plan: any) => void; +} + +const Sidebar: React.FC = ({ user, activeTab, setActiveTab, onLogout, onOpenAdmin, onOpenPro, t, coachHistory, onSelectCoachPlan }) => { + const [isCoachExpanded, setIsCoachExpanded] = useState(false); + + const handleCoachClick = () => { + // If has history, toggle submenu + if (coachHistory && coachHistory.length > 0) { + setIsCoachExpanded(!isCoachExpanded); + } + setActiveTab('coach'); + }; + + return ( + + ); +}; + +interface SidebarItemProps { + icon: React.ReactNode; + label: string; + active: boolean; + onClick: () => void; + hasSubmenu?: boolean; + isExpanded?: boolean; +} + +const SidebarItem = ({ icon, label, active, onClick, hasSubmenu, isExpanded }: SidebarItemProps) => ( + +); + +export default Sidebar; + diff --git a/src/components/modals/CalculatorsModal.tsx b/src/components/modals/CalculatorsModal.tsx new file mode 100644 index 0000000..b4c1250 --- /dev/null +++ b/src/components/modals/CalculatorsModal.tsx @@ -0,0 +1,659 @@ +import React, { useState } from 'react'; +import { X, Calculator, Droplets, Activity, Scale, ChevronRight, ArrowRight, Check, Dumbbell, Flame, Heart, Percent } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface CalculatorsModalProps { + isOpen: boolean; + onClose: () => void; +} + +type ToolType = 'bmi' | 'water' | 'bmr' | 'tdee' | 'orm' | 'bodyfat' | 'hr'; + +const CalculatorsModal: React.FC = ({ isOpen, onClose }) => { + const [activeTool, setActiveTool] = useState('bmi'); + const { t } = useLanguage(); + + if (!isOpen) return null; + + return ( + +
+ {/* Backdrop Overlay */} + + + {/* Modal Container */} + + {/* Close Button (Mobile) */} + + + {/* Sidebar Navigation */} + + + {/* Main Content Area */} +
+ {/* Close Button (Desktop) */} + + +
+ + + {activeTool === 'bmi' && } + {activeTool === 'water' && } + {activeTool === 'bmr' && } + {activeTool === 'tdee' && } + {activeTool === 'orm' && } + {activeTool === 'bodyfat' && } + {activeTool === 'hr' && } + + +
+
+
+
+
+ ); +}; + +// --- Components de Navegação --- + +const NavButton = ({ active, onClick, icon, label, desc }: any) => ( + +); + +// --- Calculadoras (Existentes + Novas) --- + +const BMICalculator = ({ t }: any) => { + const [weight, setWeight] = useState(''); + const [height, setHeight] = useState(''); + const [bmi, setBmi] = useState(null); + + const calculate = () => { + if (weight && height) { + const h = parseFloat(height) / 100; + const val = parseFloat(weight) / (h * h); + setBmi(parseFloat(val.toFixed(1))); + } + }; + + const getStatus = (val: number) => { + if (val < 18.5) return { label: 'Abaixo do peso', color: 'text-blue-500', bg: 'bg-blue-500', range: 0 }; + if (val < 25) return { label: 'Peso ideal', color: 'text-green-500', bg: 'bg-green-500', range: 33 }; + if (val < 30) return { label: 'Sobrepeso', color: 'text-yellow-500', bg: 'bg-yellow-500', range: 66 }; + return { label: 'Obesidade', color: 'text-red-500', bg: 'bg-red-500', range: 100 }; + }; + + const status = bmi ? getStatus(bmi) : null; + + return ( +
+
+

{t.tools.bmi.title}

+

{t.tools.bmi.desc}

+
+ +
+ + +
+ + + + {bmi && status && ( +
+
+

Seu Resultado

+
{bmi}
+ + {status.label} + +
+ + {/* Visual Bar */} +
+
+
+
+
+
+
+
+
+
+
+
+ 18.5 + 25.0 + 30.0 +
+
+ )} +
+ ); +}; + +const WaterCalculator = ({ t }: any) => { + const [weight, setWeight] = useState(''); + const [liters, setLiters] = useState(null); + + const calculate = () => { + if (weight) { + const val = parseFloat(weight) * 0.035; + setLiters(parseFloat(val.toFixed(1))); + } + }; + + return ( +
+
+

{t.tools.water.title}

+

{t.tools.water.desc}

+
+ +
+
+ + +
+ + {/* Visual Bottle */} +
+
+
+
+
+ + {liters && {liters}L} +
+
+
+ + {liters && ( +
+

Meta Diária

+

{liters} L

+
+ )} +
+ ); +}; + +const BMRCalculator = ({ t }: any) => { + const [gender, setGender] = useState<'male' | 'female'>('male'); + const [weight, setWeight] = useState(''); + const [height, setHeight] = useState(''); + const [age, setAge] = useState(''); + const [bmr, setBmr] = useState(null); + + const calculate = () => { + if (weight && height && age) { + let val = (10 * parseFloat(weight)) + (6.25 * parseFloat(height)) - (5 * parseFloat(age)); + val = gender === 'male' ? val + 5 : val - 161; + setBmr(Math.round(val)); + } + }; + + return ( +
+
+

{t.tools.bmr.title}

+

{t.tools.bmr.desc}

+
+ +
+ + +
+ +
+ + + +
+ + + + {bmr && ( +
+
+

Gasto em Repouso

+

Calorias que você queima parado.

+
+
+

{bmr}

+

kcal / dia

+
+
+ )} +
+ ); +}; + +const TDEECalculator = ({ t }: any) => { + const [gender, setGender] = useState<'male' | 'female'>('male'); + const [weight, setWeight] = useState(''); + const [height, setHeight] = useState(''); + const [age, setAge] = useState(''); + const [activity, setActivity] = useState(1.2); + const [tdee, setTdee] = useState(null); + + const calculate = () => { + if (weight && height && age) { + let bmr = (10 * parseFloat(weight)) + (6.25 * parseFloat(height)) - (5 * parseFloat(age)); + bmr = gender === 'male' ? bmr + 5 : bmr - 161; + setTdee(Math.round(bmr * activity)); + } + }; + + const activityLevels = [ + { val: 1.2, label: t.tools.tdee.sedentary }, + { val: 1.375, label: t.tools.tdee.light }, + { val: 1.55, label: t.tools.tdee.moderate }, + { val: 1.725, label: t.tools.tdee.active }, + { val: 1.9, label: t.tools.tdee.veryActive }, + ]; + + return ( +
+
+

{t.tools.tdee.title}

+

{t.tools.tdee.desc}

+
+ +
+ + +
+ +
+ + + +
+ +
+ +
+ {activityLevels.map((lvl) => ( + + ))} +
+
+ + + + {tdee && ( +
+
+

Gasto Calórico Total

+

Energia necessária para manter seu peso atual.

+
+
+

{tdee}

+

kcal / dia

+
+
+ )} +
+ ); +}; + +const ORMCalculator = ({ t }: any) => { + const [lift, setLift] = useState(''); + const [reps, setReps] = useState(''); + const [orm, setOrm] = useState(null); + + const calculate = () => { + if (lift && reps) { + // Epley Formula + const w = parseFloat(lift); + const r = parseFloat(reps); + if (r === 1) { + setOrm(w); + } else { + const val = w * (1 + r / 30); + setOrm(Math.round(val)); + } + } + }; + + return ( +
+
+

{t.tools.orm.title}

+

{t.tools.orm.desc}

+
+ +
+ + +
+ + + + {orm && ( +
+
+ Sua Força Máxima Estimada + +
+
+ {orm} + kg +
+
+
+ 90% + {Math.round(orm * 0.9)}kg +
+
+ 70% (Hipertrofia) + {Math.round(orm * 0.7)}kg +
+
+ 50% + {Math.round(orm * 0.5)}kg +
+
+
+ )} +
+ ); +}; + +const BodyFatCalculator = ({ t }: any) => { + const [gender, setGender] = useState<'male' | 'female'>('male'); + const [waist, setWaist] = useState(''); + const [neck, setNeck] = useState(''); + const [hip, setHip] = useState(''); + const [height, setHeight] = useState(''); + const [bf, setBf] = useState(null); + + const calculate = () => { + // US Navy Method + const h = parseFloat(height); + const w = parseFloat(waist); + const n = parseFloat(neck); + + if (gender === 'male' && h && w && n) { + const res = 495 / (1.0324 - 0.19077 * Math.log10(w - n) + 0.15456 * Math.log10(h)) - 450; + setBf(parseFloat(res.toFixed(1))); + } else if (gender === 'female' && h && w && n && hip) { + const hi = parseFloat(hip); + const res = 495 / (1.29579 - 0.35004 * Math.log10(w + hi - n) + 0.22100 * Math.log10(h)) - 450; + setBf(parseFloat(res.toFixed(1))); + } + }; + + return ( +
+
+

{t.tools.bodyfat.title}

+

{t.tools.bodyfat.desc}

+
+ +
+ + +
+ +
+ + + + {gender === 'female' && ( + + )} +
+ + + + {bf && ( +
+

Gordura Corporal Estimada

+

{bf}%

+
+ Método US Navy +
+
+ )} +
+ ); +}; + +const HeartRateCalculator = ({ t }: any) => { + const [age, setAge] = useState(''); + const [maxHr, setMaxHr] = useState(null); + + const calculate = () => { + if (age) { + const val = 220 - parseFloat(age); + setMaxHr(val); + } + }; + + return ( +
+
+

{t.tools.hr.title}

+

{t.tools.hr.desc}

+
+ +
+ +
+ + + + {maxHr && ( +
+
+ Frequência Máxima Teórica +
{maxHr} bpm
+
+ +
+ + + + +
+
+ )} +
+ ); +}; + +const ZoneBar = ({ zone, color, range, val, label }: any) => ( +
+
Z{zone}
+
+
+ {label} + {range} +
+
{val} bpm
+
+
+); + + +const BigInput = ({ label, value, onChange, placeholder, unit }: any) => ( +
+ +
+ onChange(e.target.value)} + className="w-full px-4 py-4 rounded-xl bg-gray-50 border border-gray-200 text-gray-900 font-bold text-lg focus:border-brand-500 focus:ring-2 focus:ring-brand-200 outline-none transition-all placeholder-gray-300" + placeholder={placeholder} + /> + {unit && {unit}} +
+
+); + +export default CalculatorsModal; \ No newline at end of file diff --git a/src/components/modals/RegistrationModal.tsx b/src/components/modals/RegistrationModal.tsx new file mode 100644 index 0000000..cfa95ac --- /dev/null +++ b/src/components/modals/RegistrationModal.tsx @@ -0,0 +1,409 @@ +import React, { useState, useEffect } from 'react'; +import { X, ArrowRight, Loader2, Lock, Mail, User as UserIcon, Eye, EyeOff, Phone, CheckCircle, AlertCircle } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { supabase } from '@/lib/supabase'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface RegistrationModalProps { + isOpen: boolean; + onClose: () => void; + plan: string; + mode: 'login' | 'register'; + isCompletingProfile?: boolean; + onSuccess: () => void; +} + +const onlyDigits = (s: string) => (s || '').replace(/\D/g, ''); + +const GoogleIcon = () => ( + + + + + + +); + +const RegistrationModal: React.FC = ({ + isOpen, + onClose, + plan, + mode, + isCompletingProfile = false, + onSuccess +}) => { + const { t } = useLanguage(); + const [activeMode, setActiveMode] = useState<'login' | 'register'>(mode); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + // Feedback states + const [errorMsg, setErrorMsg] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + const [formData, setFormData] = useState({ + name: '', + email: '', + phone: '', + password: '' + }); + + useEffect(() => { + if (isOpen) { + if (isCompletingProfile) { + // Se estiver completando perfil, busca dados da sessão atual + supabase.auth.getUser().then(({ data }) => { + if (data.user) { + setFormData(prev => ({ + ...prev, + email: data.user?.email || '', + name: data.user?.user_metadata?.full_name || data.user?.user_metadata?.name || '', + })); + } + }); + } else { + setActiveMode(mode); + } + + setLoading(false); + setErrorMsg(null); + setSuccessMsg(null); + setShowPassword(false); + if (!isCompletingProfile) setFormData({ name: '', email: '', phone: '', password: '' }); + } + }, [isOpen, mode, isCompletingProfile]); + + const friendlyAuthError = (msg: string) => { + const m = (msg || '').toLowerCase(); + // Simplified error mapping, could be extended to dictionary if strict multi-lang errors needed + if (m.includes('database error')) return 'Server Error.'; + if (m.includes('already registered') || m.includes('user already registered')) return 'Email already registered.'; + if (m.includes('invalid login credentials')) return 'Invalid credentials.'; + if (m.includes('password should be at least')) return 'Password too short (min 6 chars).'; + if (m.includes('email not confirmed')) return 'Please confirm your email.'; + if (m.includes('duplicate key') || m.includes('already exists')) return 'Phone or Email already in use.'; + return msg || 'An error occurred.'; + }; + + const handleGoogleLogin = async () => { + setLoading(true); + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: window.location.origin + } + }); + + if (error) { + setErrorMsg(friendlyAuthError(error.message)); + setLoading(false); + } + // Se der certo, o usuário sai da página, então não precisamos setar loading false + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setErrorMsg(null); + setSuccessMsg(null); + + try { + // --- MODO: COMPLETAR PERFIL (Vindo do Google) --- + if (isCompletingProfile) { + const phoneDigits = onlyDigits(formData.phone); + const fullName = formData.name.trim(); + + if (!fullName) throw new Error(t.auth.errorRequired); + if (!phoneDigits || phoneDigits.length < 10) throw new Error(t.auth.errorPhone); + + // RPC para salvar profile + const { error: rpcError } = await supabase.rpc('register_user_profile', { + p_full_name: fullName, + p_phone: phoneDigits, + p_email: formData.email // Email já vem do Google/Sessão + }); + + if (rpcError) { + console.error(rpcError); + throw new Error('Error saving profile. Try again.'); + } + + setSuccessMsg(t.auth.successLogin); + setTimeout(() => onSuccess(), 1500); + return; + } + + // --- MODO: REGISTRO NORMAL --- + if (activeMode === 'register') { + const email = (formData.email || '').trim().toLowerCase(); + const fullName = (formData.name || '').trim(); + const phoneDigits = onlyDigits(formData.phone); + + if (!fullName) throw new Error(t.auth.errorRequired); + if (!email) throw new Error(t.auth.errorRequired); + if (!phoneDigits) throw new Error(t.auth.errorRequired); + if (phoneDigits.length < 10) throw new Error(t.auth.errorPhone); + + const { data: authData, error: authError } = await supabase.auth.signUp({ + email, + password: formData.password, + options: { emailRedirectTo: window.location.origin } + }); + + if (authError) throw authError; + + if (!authData.user) { + setSuccessMsg(t.auth.successRegister); + setTimeout(() => onSuccess(), 2000); + return; + } + + const { error: rpcError } = await supabase.rpc('register_user_profile', { + p_full_name: fullName, + p_phone: phoneDigits, + p_email: email + }); + + if (rpcError) throw new Error('Phone/Email already in use.'); + + setSuccessMsg(t.auth.successRegister); + setTimeout(() => onSuccess(), 1500); + return; + } + + // --- MODO: LOGIN NORMAL --- + const { error: loginError } = await supabase.auth.signInWithPassword({ + email: (formData.email || '').trim().toLowerCase(), + password: formData.password + }); + + if (loginError) throw loginError; + + setSuccessMsg(t.auth.successLogin); + setTimeout(() => onSuccess(), 1500); + + } catch (error: any) { + console.error('Auth Error:', error); + setLoading(false); + const rawMsg = error?.message || error?.error_description || 'Error'; + setErrorMsg(friendlyAuthError(rawMsg)); + } + }; + + const title = isCompletingProfile ? t.auth.completeProfile : (activeMode === 'login' ? t.auth.welcomeBack : t.auth.createAccount); + const subtitle = isCompletingProfile ? t.auth.confirmPhone : (activeMode === 'login' ? t.auth.accessPanel : t.auth.fillToAccess); + + return ( + + {isOpen && ( +
+ + + +
+
+
+

{title}

+

{subtitle}

+
+ {!isCompletingProfile && ( + + )} +
+ + {errorMsg && ( + + + {errorMsg} + + )} + + {successMsg && ( + + + {successMsg} + + )} + +
+ + {/* Campos para Registro ou Completar Perfil */} + {(activeMode === 'register' || isCompletingProfile) && ( +
+ +
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ )} + + {(activeMode === 'register' || isCompletingProfile) && ( +
+ +
+ + setFormData({ ...formData, phone: e.target.value })} + /> +
+

{t.auth.phoneHelper}

+
+ )} + + {!isCompletingProfile && ( + <> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + /> +
+
+ +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + /> + +
+
+ + )} + +
+ +
+
+ + {/* Google Button & Toggle Mode */} + {!isCompletingProfile && ( +
+
+
+
{t.auth.or}
+
+ + + +
+

+ {activeMode === 'login' ? t.auth.noAccount : t.auth.hasAccount} + +

+
+
+ )} +
+ +
+ + {t.auth.security} +
+
+
+ )} +
+ ); +}; + +export default RegistrationModal; \ No newline at end of file diff --git a/src/components/professional/ProfessionalModule.tsx b/src/components/professional/ProfessionalModule.tsx new file mode 100644 index 0000000..275883d --- /dev/null +++ b/src/components/professional/ProfessionalModule.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react'; +import { + Briefcase, + Users, + DollarSign, + Settings, + Plus, + Edit2, + Trash2, + ChevronRight, + Award, + CheckCircle2, + Calendar, + MessageSquare +} from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +const ProfessionalModule: React.FC = () => { + // Mock Data for MVP + const [services, setServices] = useState([ + { id: 1, title: 'Consultoria Online Mensal', price: 15000, active: true, clients: 12 }, + { id: 2, title: 'Treino Hipertrofia Individual', price: 8990, active: true, clients: 5 }, + { id: 3, title: 'Avaliação Física Presencial', price: 12000, active: false, clients: 0 } + ]); + + const [clients] = useState([ + { id: 1, name: 'João Silva', plan: 'Consultoria Online', status: 'active', lastCheckin: 'Hoje' }, + { id: 2, name: 'Maria Oliveira', plan: 'Treino Hipertrofia', status: 'active', lastCheckin: 'Ontem' }, + { id: 3, name: 'Carlos Santos', plan: 'Consultoria Online', status: 'pending', lastCheckin: '3 dias atrás' } + ]); + + const { t } = useLanguage(); // Assuming we might add translations later, but sticking to PT for hardcoded MVP parts + + const formatCurrency = (val: number) => { + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(val / 100); + }; + + return ( +
+ {/* Header Section */} +
+
+

+ Área Profissional BETA +

+

Gerencie seus serviços, alunos e faturamento em um só lugar.

+
+
+ + +
+
+ + {/* KPI Cards */} +
+
+
+ +
+
+

Alunos Ativos

+

17

+
+
+
+
+ +
+
+

Faturamento (Mês)

+

R$ 2.450,00

+
+
+
+
+ +
+
+

Serviços Ativos

+

3

+
+
+
+ +
+ + {/* Meus Serviços */} +
+
+
+

+ Meus Serviços +

+
+
+ {services.map(service => ( +
+
+
+

{service.title}

+ {service.active ? ( + Ativo + ) : ( + Inativo + )} +
+

+ {formatCurrency(service.price)} • {service.clients} alunos inscritos +

+
+
+ + +
+
+ ))} +
+
+ +
+
+ + {/* Quick Tips / Upsell */} +
+
+

Aumente suas vendas

+

+ Profissionais que detalham bem seus serviços e usam fotos profissionais vendem 3x mais. + Configure seu perfil público agora mesmo. +

+ +
+
+
+
+ + {/* Meus Alunos (Sidebar) */} +
+
+
+

+ Alunos Recentes +

+
+
+ {clients.map(client => ( +
+
+
+ {client.name.substring(0, 2).toUpperCase()} +
+
+

{client.name}

+

{client.plan}

+
+ {client.status === 'active' ? ( + + ) : ( +
+ )} +
+
+ {client.lastCheckin} + +
+
+ ))} +
+
+ +
+
+
+
+
+ ); +}; + +export default ProfessionalModule; diff --git a/src/components/professional/common/PlaceholderModule.tsx b/src/components/professional/common/PlaceholderModule.tsx new file mode 100644 index 0000000..3e89fc1 --- /dev/null +++ b/src/components/professional/common/PlaceholderModule.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +export const PlaceholderModule = ({ title, desc, icon }: any) => ( +
+
+ {icon} +
+

{title}

+

{desc}

+ +
+); diff --git a/src/components/professional/common/StatsCard.tsx b/src/components/professional/common/StatsCard.tsx new file mode 100644 index 0000000..3237bba --- /dev/null +++ b/src/components/professional/common/StatsCard.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export const StatsCard = ({ label, value, trend, alert }: any) => ( +
+

{label}

+

{value}

+

{trend}

+
+); diff --git a/src/components/professional/dashboard/Overview.tsx b/src/components/professional/dashboard/Overview.tsx new file mode 100644 index 0000000..5e6da9e --- /dev/null +++ b/src/components/professional/dashboard/Overview.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { StatsCard } from '../common/StatsCard'; + +export const OverviewMock = () => ( +
+
+ + + +
+ +
+

Atividade Recente

+
+ {[1, 2, 3].map(i => ( +
+
US
+
+

João Silva finalizou o treino "Hipertrofia A"

+

Há 2 horas • Duração: 45min

+
+
+ ))} +
+
+
+); diff --git a/src/components/professional/dashboard/StudentsList.tsx b/src/components/professional/dashboard/StudentsList.tsx new file mode 100644 index 0000000..35d04b4 --- /dev/null +++ b/src/components/professional/dashboard/StudentsList.tsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect } from 'react'; +import { supabase } from '@/lib/supabase'; +import { User } from '@/types'; +import { Search, PlusCircle, Users, X, Calendar } from 'lucide-react'; + +interface StudentsListProps { + user: User; +} + +export const StudentsList: React.FC = ({ user }) => { + const [students, setStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + // New Student Form State + const [newName, setNewName] = useState(''); + const [newEmail, setNewEmail] = useState(''); + const [newPhone, setNewPhone] = useState(''); + + useEffect(() => { + fetchStudents(); + }, [user.id]); + + const fetchStudents = async () => { + try { + setLoading(true); + + // First, ensure the professional profile exists (Auto-create logic if missing) + const { data: proProfile } = await supabase + .from('professionals') + .select('id') + .eq('id', user.id) + .maybeSingle(); + + if (!proProfile) { + // Auto-create professional profile if it doesn't exist (First Login) + await supabase.from('professionals').insert({ + id: user.id, + business_name: user.name, + primary_color: '#059669' + }); + } + + const { data, error } = await supabase + .from('pro_students') + .select('*') + .eq('professional_id', user.id) + .order('created_at', { ascending: false }); + + if (error) throw error; + setStudents(data || []); + } catch (error) { + console.error('Error fetching students:', error); + } finally { + setLoading(false); + } + }; + + const handleCreateStudent = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const { error } = await supabase.from('pro_students').insert({ + professional_id: user.id, + name: newName, + email: newEmail, + phone: newPhone, + status: 'active' + }); + + if (error) throw error; + + setIsCreateOpen(false); + setNewName(''); + setNewEmail(''); + setNewPhone(''); + fetchStudents(); // Refresh list + } catch (error) { + console.error('Error creating student:', error); + } + }; + + const filteredStudents = students.filter(s => + s.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (s.email && s.email.toLowerCase().includes(searchTerm.toLowerCase())) + ); + + return ( +
+ {/* Header / Actions */} +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ +
+
+ + {/* List */} + {loading ? ( +
Carregando alunos...
+ ) : filteredStudents.length === 0 ? ( +
+
+ +
+

Nenhum aluno encontrado

+

Comece adicionando seu primeiro aluno.

+ +
+ ) : ( +
+ + + + + + + + + + + + {filteredStudents.map(student => ( + + + + + + + + ))} + +
AlunoStatusContatoEntrou em
+
+ {student.name.substring(0, 2)} +
+ {student.name} +
+ + {student.status === 'active' ? 'Ativo' : student.status === 'pending' ? 'Pendente' : 'Inativo'} + + +
+ {student.email} + {student.phone} +
+
{new Date(student.created_at).toLocaleDateString('pt-BR')} + +
+
+ )} + + {/* Create Modal */} + {isCreateOpen && ( +
+
+
+

Novo Aluno

+ +
+ +
+
+ + setNewName(e.target.value)} + /> +
+
+ + setNewEmail(e.target.value)} + /> +
+
+ + setNewPhone(e.target.value)} + /> +
+ + +
+
+
+ )} +
+ ); +}; diff --git a/src/components/professional/dashboard/Workouts.tsx b/src/components/professional/dashboard/Workouts.tsx new file mode 100644 index 0000000..f649c61 --- /dev/null +++ b/src/components/professional/dashboard/Workouts.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Dumbbell, Settings, PlusCircle } from 'lucide-react'; + +export const WorkoutsMock = () => ( +
+ {['Hipertrofia Iniciante', 'Emagrecimento Avançado', 'Funcional Idosos'].map((t, i) => ( +
+
+
+ +
+ +
+

{t}

+

30 alunos vinculados

+
+ ABC + 45-60min +
+
+ ))} +
+ + Criar Novo Treino +
+
+); diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx new file mode 100644 index 0000000..b2f6ef7 --- /dev/null +++ b/src/contexts/LanguageContext.tsx @@ -0,0 +1,1377 @@ +import React, { createContext, useState, useContext, ReactNode } from 'react'; + +type Language = 'pt' | 'en' | 'es'; + +interface PlanDetails { + title: string; + price: string; + period: string; + billingInfo: string; + description: string; + btnText: string; + features: string[]; + highlight?: string; + savings?: string; +} + +interface FaqItem { + q: string; + a: string; +} + +interface FaqCategory { + title: string; + items: FaqItem[]; +} + +interface Translations { + header: { + howItWorks: string; + features: string; + pricing: string; + login: string; + cta: string; + slogan: string; + tools: string; + }; + hero: { + tag: string; + titleStart: string; + titleHighlight: string; + subtitle: string; + ctaUpload: string; + ctaPlans: string; + stats: string; + analysis: string; + demoTag: string; + demoResult: string; + demoAdvice: string; + demoAdviceText: string; + demoModalTitle: string; + demoModalDesc: string; + demoModalBtn: string; + demoProcessing: string; + }; + howItWorks: { + title: string; + subtitle: string; + step1Title: string; + step1Desc: string; + step2Title: string; + step2Desc: string; + step3Title: string; + step3Desc: string; + }; + features: { + guruTitle: string; + mainTitle: string; + subtitle: string; + f1Title: string; + f1Desc: string; + f2Title: string; + f2Desc: string; + f3Title: string; + f3Desc: string; + f4Title: string; + f4Desc: string; + f5Title: string; + f5Desc: string; + visualTipTitle: string; + visualTipDesc: string; + }; + testimonials: { + title: string; + subtitle: string; + r1Content: string; + r1Role: string; + r2Content: string; + r2Role: string; + r3Content: string; + r3Role: string; + }; + pricing: { + title: string; + subtitle: string; + freeTierTitle: string; + freeTierDesc: string; + secure: string; + plans: { + monthly: PlanDetails; + quarterly: PlanDetails; + annual: PlanDetails; + } + }; + faq: { + title: string; + q1: string; a1: string; + q2: string; a2: string; + q3: string; a3: string; + q4: string; a4: string; + }; + // New Full FAQ Page + faqPage: { + title: string; + subtitle: string; + searchPlaceholder: string; + backHome: string; + categories: { + general: FaqCategory; + account: FaqCategory; + billing: FaqCategory; + technical: FaqCategory; + }; + }; + footer: { + ctaTitle: string; + ctaDesc: string; + ctaBtn: string; + desc: string; + platform: string; + legal: string; + connect: string; + rights: string; + }; + auth: { + welcomeBack: string; + createAccount: string; + completeProfile: string; + accessPanel: string; + fillToAccess: string; + confirmPhone: string; + nameLabel: string; + phoneLabel: string; + emailLabel: string; + passwordLabel: string; + phonePlaceholder: string; + phoneHelper: string; + btnRegister: string; + btnLogin: string; + btnSave: string; + btnSuccess: string; + googleBtn: string; + or: string; + noAccount: string; + hasAccount: string; + registerLink: string; + loginLink: string; + security: string; + errorRequired: string; + errorPhone: string; + successRegister: string; + successLogin: string; + }; + dashboard: { + menuOverview: string; + menuHistory: string; + menuSubscription: string; + logout: string; + hello: string; + status: string; + statDishes: string; + statDishesSub: string; + statCals: string; + statCalsSub: string; + statPlan: string; + activeSub: string; + trialSub: string; + upgradeSub: string; + eatTitle: string; + eatDesc: string; + btnHistory: string; + btnWhatsapp: string; + recentTitle: string; + viewAll: string; + emptyRecent: string; + historyTitle: string; + historySubtitle: string; + searchPlaceholder: string; + emptyHistory: string; + subTitle: string; + subDesc: string; + currentPlan: string; + validUntil: string; + limitedAccess: string; + portalText: string; + btnPortal: string; + upgradeTitle: string; + upgradeDesc: string; + btnUpgrade: string; + btnUpgradeShort: string; + connectTitle: string; + connectDesc: string; + step1: string; + step2: string; + step3: string; + scanLabel: string; + }; + tools: { + title: string; + subtitle: string; + bmi: { + title: string; + desc: string; + labelWeight: string; + labelHeight: string; + result: string; + }; + water: { + title: string; + desc: string; + result: string; + daily: string; + }; + bmr: { + title: string; + desc: string; + labelAge: string; + labelGender: string; + male: string; + female: string; + result: string; + }; + tdee: { + title: string; + desc: string; + activity: string; + sedentary: string; + light: string; + moderate: string; + active: string; + veryActive: string; + result: string; + }; + orm: { + title: string; + desc: string; + lift: string; + reps: string; + result: string; + }; + bodyfat: { + title: string; + desc: string; + waist: string; + neck: string; + hip: string; + result: string; + }; + hr: { + title: string; + desc: string; + result: string; + zone: string; + }; + calculate: string; + back: string; + }; + coach: { + title: string; + subtitle: string; + photosStep: { + alert: string; + front: string; + side: string; + back: string; + camera: string; + gallery: string; + }; + goalStep: { + title: string; + hypertrophy: { title: string; desc: string; }; + definition: { title: string; desc: string; }; + maintenance: { title: string; desc: string; }; + strength: { title: string; desc: string; }; + }; + processing: { + errorTitle: string; + retry: string; + analyzing: string; + wait: string; + steps: string[]; + }; + buttons: { + next: string; + back: string; + generate: string; + }; + }; +} + +const dictionary: Record = { + pt: { + header: { + howItWorks: 'Como Funciona', + features: 'Vantagens', + pricing: 'Planos', + login: 'Área do Membro', + cta: 'Começar Transformação', + slogan: 'Seu Nutricionista de Bolso', + tools: 'Ferramentas' + }, + hero: { + tag: 'NOVA IA GERATIVA 2.0', + titleStart: 'Transforme seu corpo', + titleHighlight: 'com apenas uma foto.', + subtitle: 'O FoodSnap.ai elimina a necessidade de pesar comida. Tire uma foto e nossa Inteligência Artificial calcula calorias, macros e te dá dicas em tempo real para atingir seu peso ideal.', + ctaUpload: 'Testar IA Agora', + ctaPlans: 'Ver Preços', + stats: '25k+ refeições otimizadas', + analysis: 'Raio-X Nutricional', + demoTag: 'Análise FoodSnap', + demoResult: 'Estimativa Precisa', + demoAdvice: 'Dica FoodSnap:', + demoAdviceText: 'Ótima proteína! Reduza o arroz pela metade no jantar para acelerar a queima de gordura.', + demoModalTitle: 'Experimente a Mágica', + demoModalDesc: 'Envie uma foto da sua refeição. O FoodSnap.ai identifica ingredientes e calcula tudo instantaneamente.', + demoModalBtn: 'Enviar Foto', + demoProcessing: 'FoodSnap analisando...' + }, + howItWorks: { + title: 'Nutrição simplificada em 3 passos', + subtitle: 'Sem planilhas chatas, sem aplicativos complicados. Usamos o WhatsApp que você já ama.', + step1Title: 'Fotografe', + step1Desc: 'Vai comer? Aponte a câmera. Não precisa descrever nada, nossa visão computacional faz o trabalho pesado.', + step2Title: 'Envie no Zap', + step2Desc: 'Mande a foto para o nosso número oficial. É como conversar com um amigo nutricionista.', + step3Title: 'Receba o Feedback', + step3Desc: 'Em segundos, saiba se está dentro da meta e receba sugestões para melhorar a próxima refeição.' + }, + features: { + guruTitle: 'Tecnologia FoodSnap', + mainTitle: 'Emagreça sem passar fome e sem neura.', + subtitle: 'O FoodSnap não é apenas um contador de calorias. É um assistente que te ensina a comer melhor, refeição após refeição.', + f1Title: 'Scanner de Calorias', + f1Desc: 'Adeus balança! Nossa IA estima porções visualmente com alta precisão.', + f2Title: 'Coach Nutricional', + f2Desc: 'Receba elogios quando acertar e correções gentis quando exagerar. Feedback humano e motivador.', + f3Title: 'Sugestões de Trocas', + f3Desc: 'Exagerou no almoço? O FoodSnap sugere um lanche mais leve para compensar e manter o dia no verde.', + f4Title: 'Diário Automático', + f4Desc: 'Todo o seu histórico fica salvo. Gere relatórios de evolução para compartilhar com seu médico.', + f5Title: 'Tire Dúvidas 24h', + f5Desc: 'Pergunte qualquer coisa: "Quantas calorias tem uma maçã?" ou "O que comer antes do treino?".', + visualTipTitle: 'Insight FoodSnap', + visualTipDesc: 'Essa refeição está rica em fibras! Isso vai te manter saciado por mais tempo. Continue assim!' + }, + testimonials: { + title: 'Resultados Reais', + subtitle: 'Milhares de pessoas já transformaram sua relação com a comida usando o FoodSnap.ai.', + r1Content: 'Eu desisti de 5 dietas porque tinha preguiça de anotar tudo. Com o FoodSnap, só tiro foto e pronto. Perdi 8kg em 2 meses!', + r1Role: 'Advogado', + r2Content: 'Como nutricionista, recomendo aos pacientes. A adesão ao plano alimentar triplicou porque ficou divertido acompanhar.', + r2Role: 'Nutricionista Funcional', + r3Content: 'A precisão é impressionante. Ele identificou até o azeite na salada. Vale cada centavo pela praticidade.', + r3Role: 'Personal Trainer' + }, + pricing: { + title: 'Invista na sua Saúde', + subtitle: 'Menos que um café por dia para ter um nutricionista IA no seu bolso.', + freeTierTitle: 'Teste Gratuito', + freeTierDesc: 'Analise suas primeiras 5 refeições sem custo algum.', + secure: 'Compra segura. Satisfação garantida ou seu dinheiro de volta em 7 dias.', + plans: { + monthly: { + title: 'Mensal', + price: 'R$ 49,90', + period: '/mês', + billingInfo: 'Sem fidelidade', + description: 'Para quem quer flexibilidade.', + btnText: 'Começar Mensal', + features: [ + 'Fotos Ilimitadas', + 'Feedback Imediato', + 'Chat Nutricional 24h', + 'Histórico Completo' + ] + }, + quarterly: { + title: 'Trimestral', + price: 'R$ 39,90', + period: '/mês', + billingInfo: 'Cobrado a cada 3 meses', + description: 'O empurrão que faltava.', + btnText: 'Garantir Desconto', + features: [ + 'Tudo do Mensal', + 'Economia de 20%', + 'Prioridade no Suporte', + 'Acesso a Novas Features' + ] + }, + annual: { + title: 'Anual', + price: 'R$ 29,90', + period: '/mês', + billingInfo: 'Faturamento anual', + description: 'Para transformar o estilo de vida.', + btnText: 'Quero o Melhor Preço', + highlight: 'Mais Vendido', + savings: 'Economize 40%', + features: [ + 'Tudo do Trimestral', + 'Relatório Mensal em PDF', + 'Suporte VIP', + 'Menor valor mensal' + ] + } + } + }, + faq: { + title: 'Dúvidas Comuns', + q1: 'O FoodSnap substitui um nutricionista?', + a1: 'O FoodSnap é uma ferramenta de apoio educacional e monitoramento. Para dietas prescritas para condições de saúde específicas, consulte sempre um profissional.', + q2: 'Preciso baixar algum app?', + a2: 'Não! Toda a mágica acontece no WhatsApp. Você não ocupa memória do celular e usa o app que já conhece.', + q3: 'A IA acerta sempre?', + a3: 'Nossa IA tem precisão superior a 90% para alimentos comuns. Em pratos muito misturados, ela faz a melhor estimativa possível baseada em padrões visuais.', + q4: 'Como cancelo?', + a4: 'Super simples. Dentro do seu painel de usuário, há um botão "Gerenciar Assinatura". Cancele quando quiser com um clique.' + }, + faqPage: { + title: 'Central de Ajuda', + subtitle: 'Encontre respostas para suas dúvidas sobre o FoodSnap.ai', + searchPlaceholder: 'Busque sua dúvida (ex: cancelamento, whatsapp...)', + backHome: 'Voltar para Home', + categories: { + general: { + title: 'Geral', + items: [ + { q: 'O que é o FoodSnap.ai?', a: 'O FoodSnap.ai é um serviço de nutrição inteligente que usa inteligência artificial para analisar fotos de suas refeições, calcular calorias e macros, e fornecer feedback em tempo real via WhatsApp.' }, + { q: 'Como funciona a análise?', a: 'Basta enviar uma foto do seu prato para nosso número no WhatsApp. Nossa IA identifica os alimentos, estima as porções e retorna um relatório nutricional completo em segundos.' }, + { q: 'Preciso de um app?', a: 'Não. Tudo funciona dentro do WhatsApp. Você também tem acesso a um painel web para ver seu histórico completo e gráficos de evolução.' } + ] + }, + account: { + title: 'Minha Conta', + items: [ + { q: 'Como mudo minha senha?', a: 'Você pode redefinir sua senha na tela de login clicando em "Esqueci minha senha" ou dentro do painel do usuário nas configurações.' }, + { q: 'Posso compartilhar minha conta?', a: 'A assinatura é individual. O histórico e as recomendações são personalizados para o perfil de um único usuário.' }, + { q: 'Como mudo meu telefone cadastrado?', a: 'No momento, para mudar o telefone vinculado ao WhatsApp, entre em contato com nosso suporte via chat no painel.' } + ] + }, + billing: { + title: 'Planos e Pagamento', + items: [ + { q: 'Quais as formas de pagamento?', a: 'Aceitamos cartões de crédito (Visa, Mastercard, Elo, Amex) através da plataforma segura Stripe.' }, + { q: 'Tem fidelidade?', a: 'O plano mensal não tem fidelidade. Os planos trimestrais e anuais possuem o compromisso pelo período contratado em troca de um desconto maior.' }, + { q: 'Como cancelo?', a: 'Acesse o Painel do Usuário > Minha Assinatura > Portal do Cliente. Lá você pode cancelar a renovação automática a qualquer momento.' }, + { q: 'Tenho reembolso?', a: 'Sim, oferecemos garantia incondicional de 7 dias para novos assinantes. Se não gostar, devolvemos 100% do valor.' } + ] + }, + technical: { + title: 'Suporte Técnico', + items: [ + { q: 'O bot não responde no WhatsApp', a: 'Verifique se você salvou o número corretamente e se seu plano está ativo. Às vezes, pode haver uma breve fila de processamento. Tente enviar "Oi" para reiniciar.' }, + { q: 'A IA identificou errado meu prato', a: 'Embora rara, imprecisões podem ocorrer. Tente tirar fotos com boa iluminação e onde todos os ingredientes estejam visíveis. Você pode enviar uma mensagem de texto corrigindo (ex: "não é frango, é peixe") e a IA ajustará.' } + ] + } + } + }, + footer: { + ctaTitle: 'Comece sua transformação hoje', + ctaDesc: 'Não deixe para segunda-feira. Sua melhor versão começa com o próximo prato.', + ctaBtn: 'Quero Experimentar o FoodSnap', + desc: 'Tecnologia de ponta para simplificar a nutrição e promover saúde acessível para todos.', + platform: 'Produto', + legal: 'Legal', + connect: 'Redes', + rights: 'Todos os direitos reservados.' + }, + auth: { + welcomeBack: 'Login FoodSnap', + createAccount: 'Criar Conta FoodSnap', + completeProfile: 'Quase lá!', + accessPanel: 'Gerencie sua assinatura e histórico.', + fillToAccess: 'Preencha para liberar seu acesso.', + confirmPhone: 'Confirme seu WhatsApp para sincronizar.', + nameLabel: 'Nome Completo', + phoneLabel: 'Seu WhatsApp (com DDD)', + emailLabel: 'Melhor E-mail', + passwordLabel: 'Senha Segura', + phonePlaceholder: '11999999999', + phoneHelper: 'Digite apenas números com DDD.', + btnRegister: 'Criar Conta Grátis', + btnLogin: 'Entrar no Painel', + btnSave: 'Finalizar Cadastro', + btnSuccess: 'Sucesso!', + googleBtn: 'Entrar com Google', + or: 'Ou', + noAccount: 'Ainda não tem conta?', + hasAccount: 'Já é cliente?', + registerLink: 'Criar conta', + loginLink: 'Fazer login', + security: 'Seus dados estão 100% seguros.', + errorRequired: 'Preencha todos os campos.', + errorPhone: 'WhatsApp inválido.', + successRegister: 'Conta criada! Acessando...', + successLogin: 'Login efetuado!' + }, + dashboard: { + menuOverview: 'Visão Geral', + menuHistory: 'Diário Alimentar', + menuSubscription: 'Minha Assinatura', + logout: 'Sair', + hello: 'Olá', + status: 'Status da Conta', + statDishes: 'Refeições', + statDishesSub: 'Registradas', + statCals: 'Média Diária', + statCalsSub: 'Calorias', + statPlan: 'Seu Plano', + activeSub: 'FoodSnap PRO', + trialSub: 'Período Gratuito', + upgradeSub: 'Conta Básica', + eatTitle: 'Hora de comer?', + eatDesc: 'Envie a foto no WhatsApp ou faça upload aqui para registrar seus macros.', + btnHistory: 'Ver Diário', + btnWhatsapp: 'Abrir WhatsApp', + recentTitle: 'Últimas Refeições', + viewAll: 'Ver tudo', + emptyRecent: 'Nenhuma refeição registrada hoje.', + historyTitle: 'Seu Diário Alimentar', + historySubtitle: 'Acompanhe sua evolução nutricional.', + searchPlaceholder: 'Buscar refeição...', + emptyHistory: 'Seu histórico está vazio.', + subTitle: 'Assinatura', + subDesc: 'Gerencie seu plano FoodSnap.', + currentPlan: 'Plano Atual', + validUntil: 'Renova em:', + limitedAccess: 'Você está no plano gratuito.', + portalText: 'Gerenciar cartão ou cancelar assinatura?', + btnPortal: 'Portal do Cliente', + upgradeTitle: 'Seja FoodSnap PRO', + upgradeDesc: 'Tenha análises ilimitadas, relatórios detalhados e suporte prioritário para atingir seus objetivos mais rápido.', + btnUpgrade: 'Assinar Agora', + btnUpgradeShort: 'Virar PRO', + connectTitle: 'Ativar no WhatsApp', + connectDesc: 'Sincronize sua conta para enviar fotos direto pelo Zap.', + step1: 'Leia o QR Code', + step2: 'Envie um "Oi"', + step3: 'Mande a foto do prato', + scanLabel: 'Ler QR Code' + }, + tools: { + title: 'Calculadoras Fit', + subtitle: 'Ferramentas para seu planejamento.', + bmi: { + title: 'Calculadora de IMC', + desc: 'Descubra seu Índice de Massa Corporal.', + labelWeight: 'Peso (kg)', + labelHeight: 'Altura (cm)', + result: 'Seu IMC é' + }, + water: { + title: 'Hidratação Diária', + desc: 'Descubra quanta água beber.', + result: 'Sua meta é', + daily: 'litros por dia' + }, + bmr: { + title: 'Gasto Calórico (TMB)', + desc: 'Quantas calorias você queima em repouso.', + labelAge: 'Idade', + labelGender: 'Gênero', + male: 'Homem', + female: 'Mulher', + result: 'Sua TMB é' + }, + tdee: { + title: 'Gasto Total (TDEE)', + desc: 'Calorias diárias considerando atividade.', + activity: 'Nível de Atividade', + sedentary: 'Sedentário', + light: 'Levemente Ativo', + moderate: 'Moderadamente Ativo', + active: 'Muito Ativo', + veryActive: 'Atleta / Extremo', + result: 'Gasto Diário' + }, + orm: { + title: 'Força Máxima (1RM)', + desc: 'Carga máxima teórica para 1 repetição.', + lift: 'Peso Levantado (kg)', + reps: 'Repetições', + result: 'Seu 1RM Estimado' + }, + bodyfat: { + title: 'Gordura Corporal', + desc: 'Estimativa baseada no método da Marinha.', + waist: 'Cintura (cm)', + neck: 'Pescoço (cm)', + hip: 'Quadril (cm)', + result: 'Gordura Estimada' + }, + hr: { + title: 'Zonas de FC', + desc: 'Frequência Cardíaca Máxima e Zonas.', + result: 'FC Máxima', + zone: 'Zona de Queima de Gordura' + }, + calculate: 'Calcular', + back: 'Voltar' + }, + coach: { + title: 'Personal Trainer IA', + subtitle: 'Gere seu protocolo ideal baseado no seu biotipo.', + photosStep: { + alert: 'Fotos de Avaliação: Use roupas leves. Posicione a câmera na altura do peito, corpo relaxado.', + front: 'Frente', + side: 'Perfil', + back: 'Costas', + camera: 'Câmera', + gallery: 'Galeria' + }, + goalStep: { + title: 'Qual seu objetivo principal?', + hypertrophy: { title: 'Hipertrofia', desc: 'Ganhar massa muscular e volume.' }, + definition: { title: 'Definição', desc: 'Queimar gordura e definir músculos.' }, + maintenance: { title: 'Saúde e Manutenção', desc: 'Melhorar alimentação e energia.' }, + strength: { title: 'Força Pura', desc: 'Focar em progressão de carga.' } + }, + processing: { + errorTitle: 'Ops! Algo deu errado.', + retry: 'Tentar Novamente', + analyzing: 'Buscando biotipo...', + wait: 'A IA está processando suas fotos. Isso pode levar até 30 segundos.', + steps: [ + "Identificando Biotipo...", + "Analisando Postura e Simetria...", + "Calculando Estimativa de Gordura...", + "Ajustando Macros para seu Objetivo...", + "Gerando Treino Personalizado..." + ] + }, + buttons: { + next: 'Próximo', + back: 'Voltar', + generate: 'Gerar Protocolo' + } + } + }, + en: { + header: { + howItWorks: 'How it Works', + features: 'Features', + pricing: 'Pricing', + login: 'Login', + cta: 'Start for Free', + slogan: 'Intelligence on your plate', + tools: 'Tools' + }, + hero: { + tag: 'Computational Nutrition AI', + titleStart: 'Calorie science,', + titleHighlight: 'simplified in one snap.', + subtitle: 'More than just calories. Our AI analyzes nutrient quality, suggests smart swaps, and optimizes your diet in real-time.', + ctaUpload: 'Live Demo', + ctaPlans: 'View Pro Plans', + stats: '10k+ meals analyzed', + analysis: 'Full Analysis', + demoTag: 'Insight', + demoResult: 'Estimate', + demoAdvice: 'Tip:', + demoAdviceText: 'Great choice! To lower the glycemic index, consider adding more fiber.', + demoModalTitle: 'Try the Technology', + demoModalDesc: 'Take a photo of your dish or choose from gallery. Our AI will analyze the nutrients in seconds.', + demoModalBtn: 'Choose Photo', + demoProcessing: 'Analyzing food items...' + }, + howItWorks: { + title: 'Frictionless Flow', + subtitle: 'We eliminated the complexity of tracking your diet. Just point and send.', + step1Title: 'Visual Capture', + step1Desc: 'Take a clear photo of your dish. Our AI accepts varied angles and identifies multiple items.', + step2Title: 'Instant Send', + step2Desc: 'Share via WhatsApp. No forms, no complex logins, no barriers.', + step3Title: 'Detailed Analysis', + step3Desc: 'Receive the full nutritional report and ask questions to the assistant in real-time.' + }, + features: { + guruTitle: 'Advanced AI', + mainTitle: 'Your pocket nutritionist, available 24/7.', + subtitle: 'We don\'t just deliver numbers. Our AI understands the context of your diet and offers qualitative feedback to help you eat better.', + f1Title: 'Nutritional X-Ray', + f1Desc: 'Automatic ingredient identification with detailed breakdown of Proteins, Carbs, Fats, and Fiber.', + f2Title: 'Improvement Suggestions', + f2Desc: 'The AI doesn\'t just read, it opines. Get tips like: "Add green leaves for more satiety".', + f3Title: 'Smart Swaps', + f3Desc: 'Love eating well? The system suggests tasty substitutions to reduce calories without sacrificing pleasure.', + f4Title: 'Visual Volumetrics', + f4Desc: 'Intelligent weight estimation based on plate proportion. Retire the kitchen scale.', + f5Title: 'Chat Consulting', + f5Desc: 'Ask questions: "Can I eat this pre-workout?" or "What\'s the best option on this menu?".', + visualTipTitle: 'Smart Insight', + visualTipDesc: 'Great choice of fats! How about adding pumpkin seeds for more crunch and zinc?' + }, + testimonials: { + title: 'Approved by Users', + subtitle: 'Join a community focused on real results.', + r1Content: 'The accuracy of macro reading changed my game. I no longer waste time weighing food at restaurants.', + r1Role: 'Crossfit Athlete', + r2Content: 'I recommend it to all my patients who struggle with food diaries. Plan adherence increased by 40%.', + r2Role: 'Sports Nutritionist', + r3Content: 'Clean interface, fast, and frictionless. Exactly what I needed to stay in shape without stress.', + r3Role: 'Software Engineer' + }, + pricing: { + title: 'Pro Plans', + subtitle: 'Choose the flexibility your lifestyle demands.', + freeTierTitle: 'Start Free', + freeTierDesc: 'All users start with 5 free queries.', + secure: 'Secure payment via Stripe. Cancel anytime.', + plans: { + monthly: { + title: 'Monthly', + price: '$9.99', + period: '/mo', + billingInfo: 'Billed monthly', + description: 'Total flexibility.', + btnText: 'Subscribe Monthly', + features: [ + 'Unlimited Queries', + 'AI Nutritionist Chat', + 'Unlimited History', + 'Micronutrient Analysis' + ] + }, + quarterly: { + title: 'Quarterly', + price: '$7.99', + period: '/mo', + billingInfo: 'Billed every 3 months', + description: 'Medium term commitment.', + btnText: 'Choose Quarterly', + features: [ + 'All Monthly features', + 'Priority Support', + 'Save 20%', + 'Access to Beta features' + ] + }, + annual: { + title: 'Annual', + price: '$5.99', + period: '/mo', + billingInfo: 'Billed annually', + description: 'Best for real results.', + btnText: 'Go Annual', + highlight: 'Best Value', + savings: 'Save 40%', + features: [ + 'All Quarterly features', + 'Evolution Reports', + 'VIP Support', + 'Price locked for 1 year' + ] + } + } + }, + faq: { + title: 'Frequently Asked Questions', + q1: 'Is the information 100% accurate?', + a1: 'No. Analyses are estimates based on the visual image sent. Factors like preparation method and hidden oils can vary. Use as a guide.', + q2: 'Does it work with any food?', + a2: 'Yes! Works well with homemade meals, lunchboxes, restaurants, and fast food. The clearer the photo, the better.', + q3: 'Do I need to install an app?', + a3: 'No. Everything works directly through WhatsApp. You send the photo as if chatting with a friend.', + q4: 'Can I cancel anytime?', + a4: 'Yes, no strings attached. Cancel the Pro subscription anytime via the dashboard.' + }, + faqPage: { + title: 'Help Center', + subtitle: 'Find answers to your questions about FoodSnap.ai', + searchPlaceholder: 'Search your question (ex: cancel, whatsapp...)', + backHome: 'Back to Home', + categories: { + general: { + title: 'General', + items: [ + { q: 'What is FoodSnap.ai?', a: 'FoodSnap.ai is an intelligent nutrition service that uses AI to analyze photos of your meals, calculate calories/macros, and provide real-time feedback via WhatsApp.' }, + { q: 'How does analysis work?', a: 'Just send a photo of your dish to our WhatsApp number. Our AI identifies foods, estimates portions, and returns a full nutritional report in seconds.' }, + { q: 'Do I need an app?', a: 'No. Everything works within WhatsApp. You also get a web dashboard to view your full history and progress charts.' } + ] + }, + account: { + title: 'My Account', + items: [ + { q: 'How do I change my password?', a: 'You can reset your password at the login screen by clicking "Forgot Password" or inside the user dashboard under settings.' }, + { q: 'Can I share my account?', a: 'Subscriptions are individual. History and recommendations are personalized for a single user profile.' }, + { q: 'How to change registered phone?', a: 'Currently, to change the phone linked to WhatsApp, please contact support via chat in the dashboard.' } + ] + }, + billing: { + title: 'Plans & Billing', + items: [ + { q: 'Payment methods?', a: 'We accept credit cards (Visa, Mastercard, Amex) via the secure Stripe platform.' }, + { q: 'Is there a contract?', a: 'The monthly plan has no contract. Quarterly and annual plans have a commitment for the contracted period in exchange for a discount.' }, + { q: 'How to cancel?', a: 'Go to User Dashboard > My Subscription > Customer Portal. You can cancel auto-renewal there anytime.' }, + { q: 'Refund policy?', a: 'Yes, we offer an unconditional 7-day guarantee for new subscribers. If you don\'t like it, we refund 100%.' } + ] + }, + technical: { + title: 'Technical Support', + items: [ + { q: 'Bot not responding on WhatsApp', a: 'Check if you saved the number correctly and your plan is active. Sometimes there may be a short processing queue. Try sending "Hi" to restart.' }, + { q: 'AI identified my dish wrong', a: 'Although rare, inaccuracies can happen. Try taking photos with good lighting where all ingredients are visible. You can send a text correcting it (e.g., "it\'s not chicken, it\'s fish") and the AI will adjust.' } + ] + } + } + }, + footer: { + ctaTitle: 'Ready to take control?', + ctaDesc: 'No complex spreadsheets, no scales. Just you, your food, and the best AI technology.', + ctaBtn: 'Access FoodSnap', + desc: 'Artificial Intelligence applied to nutrition to simplify life for those seeking health and real performance.', + platform: 'Platform', + legal: 'Legal', + connect: 'Connect', + rights: 'All rights reserved.' + }, + auth: { + welcomeBack: 'Welcome Back', + createAccount: 'Create Account', + completeProfile: 'Complete Profile', + accessPanel: 'Access your dashboard and history.', + fillToAccess: 'Fill to access AI features.', + confirmPhone: 'Confirm your WhatsApp to receive analyses.', + nameLabel: 'Full Name', + phoneLabel: 'WhatsApp (with Country Code)', + emailLabel: 'Email', + passwordLabel: 'Password', + phonePlaceholder: '15551234567', + phoneHelper: 'Numbers only, include country code.', + btnRegister: 'Create Free Account', + btnLogin: 'Login to Dashboard', + btnSave: 'Save and Continue', + btnSuccess: 'Success!', + googleBtn: 'Google', + or: 'Or continue with', + noAccount: 'No account?', + hasAccount: 'Already have an account?', + registerLink: 'Sign up', + loginLink: 'Login', + security: 'Data protected and encrypted.', + errorRequired: 'All fields are required.', + errorPhone: 'Invalid WhatsApp.', + successRegister: 'Account created! Redirecting...', + successLogin: 'Login successful!' + }, + dashboard: { + menuOverview: 'Overview', + menuHistory: 'Dish History', + menuSubscription: 'Subscription', + logout: 'Logout', + hello: 'Hello', + status: 'Status', + statDishes: 'Dishes Analyzed', + statDishesSub: 'Total registered', + statCals: 'Average Calories', + statCalsSub: 'kcal/meal', + statPlan: 'Current Plan', + activeSub: 'Active Subscription', + trialSub: 'Free Trial', + upgradeSub: 'Upgrade Available', + eatTitle: 'Eating something now?', + eatDesc: 'Send a photo to our WhatsApp or upload directly here to register.', + btnHistory: 'View History', + btnWhatsapp: 'WhatsApp', + recentTitle: 'Recent', + viewAll: 'View all', + emptyRecent: 'No dishes registered yet. Take a photo!', + historyTitle: 'Food History', + historySubtitle: 'All your analyses saved automatically.', + searchPlaceholder: 'Search dish...', + emptyHistory: 'You haven\'t sent any photos yet.', + subTitle: 'Manage Subscription', + subDesc: 'Control your payments and plan via Stripe.', + currentPlan: 'Current Plan', + validUntil: 'Valid until:', + limitedAccess: 'Limited access to free plan.', + portalText: 'Do you want to change your credit card or cancel subscription?', + btnPortal: 'Open Customer Portal', + upgradeTitle: 'Upgrade to PRO', + upgradeDesc: 'Unlock unlimited history, detailed micronutrient analysis, and priority support.', + btnUpgrade: 'Subscribe for $9.90/mo', + btnUpgradeShort: 'Get Pro', + connectTitle: 'Connect Now', + connectDesc: 'Follow steps to activate AI on WhatsApp.', + step1: 'Scan QR Code', + step2: 'Send "Hi"', + step3: 'Send photo of your dish', + scanLabel: 'Scan to start' + }, + tools: { + title: 'Free Tools', + subtitle: 'Essential calculators for your journey.', + bmi: { + title: 'BMI Calculator', + desc: 'Find out your Body Mass Index.', + labelWeight: 'Weight (kg)', + labelHeight: 'Height (cm)', + result: 'Your BMI is' + }, + water: { + title: 'Daily Hydration', + desc: 'Find out how much water to drink.', + result: 'Your goal is', + daily: 'liters per day' + }, + bmr: { + title: 'Caloric Burn (BMR)', + desc: 'How many calories you burn at rest.', + labelAge: 'Age', + labelGender: 'Gender', + male: 'Male', + female: 'Female', + result: 'Your BMR is' + }, + tdee: { + title: 'Total Energy (TDEE)', + desc: 'Daily calories including activity level.', + activity: 'Activity Level', + sedentary: 'Sedentary', + light: 'Lightly Active', + moderate: 'Moderadamente Active', + active: 'Very Active', + veryActive: 'Athlete / Extreme', + result: 'Daily Burn' + }, + orm: { + title: 'One Rep Max (1RM)', + desc: 'Theoretical max load for 1 repetition.', + lift: 'Weight Lifted (kg)', + reps: 'Reps Performed', + result: 'Estimated 1RM' + }, + bodyfat: { + title: 'Body Fat %', + desc: 'Estimate based on US Navy method.', + waist: 'Waist (cm)', + neck: 'Neck (cm)', + hip: 'Hip (cm)', + result: 'Est. Body Fat' + }, + hr: { + title: 'Heart Rate Zones', + desc: 'Max Heart Rate and Training Zones.', + result: 'Max HR', + zone: 'Fat Burn Zone' + }, + calculate: 'Calculate', + back: 'Back' + }, + coach: { + title: 'AI Personal Coach', + subtitle: 'Generate your ideal protocol based on your biotype.', + photosStep: { + alert: 'Assessment Photos: Wear light clothing. Position camera at chest height, relaxed body and straight spine.', + front: 'Front', + side: 'Side', + back: 'Back', + camera: 'Camera', + gallery: 'Gallery' + }, + goalStep: { + title: 'What is your main goal?', + hypertrophy: { title: 'Hypertrophy', desc: 'Gain muscle mass and volume.' }, + definition: { title: 'Definition', desc: 'Burn fat and define muscles.' }, + maintenance: { title: 'Health & Maintenance', desc: 'Improve nutrition and energy.' }, + strength: { title: 'Pure Strength', desc: 'Focus on load progression.' } + }, + processing: { + errorTitle: 'Oops! Something went wrong.', + retry: 'Try Again', + analyzing: 'Finding biotype...', + wait: 'AI is processing your photos. This may take up to 30 seconds.', + steps: [ + "Identifying Biotype...", + "Analyzing Posture and Symmetry...", + "Calculating Fat Estimate...", + "Adjusting Macros for your Goal...", + "Generating Personalized Workout Plan..." + ] + }, + buttons: { + next: 'Next', + back: 'Back', + generate: 'Generate Protocol' + } + } + }, + es: { + header: { + howItWorks: 'Cómo Funciona', + features: 'Funciones', + pricing: 'Precios', + login: 'Entrar', + cta: 'Empezar Gratis', + slogan: 'Inteligencia en tu plato', + tools: 'Herramientas' + }, + hero: { + tag: 'IA de Nutrición Computacional', + titleStart: 'La ciencia de las calorías,', + titleHighlight: 'simplificada en una foto.', + subtitle: 'Mucho más que calorías. Nuestra IA analiza la calidad de los nutrientes, sugiere cambios inteligentes y optimiza tu dieta en tiempo real.', + ctaUpload: 'Demostración', + ctaPlans: 'Ver Planes Pro', + stats: '10k+ platos analizados', + analysis: 'Análisis Completo', + demoTag: 'Perspectiva', + demoResult: 'Estimación', + demoAdvice: 'Consejo:', + demoAdviceText: '¡Excelente elección! Para reducir el índice glucémico, añade más fibra.', + demoModalTitle: 'Prueba la Tecnología', + demoModalDesc: 'Toma una foto de tu plato o elige de la galería. Nuestra IA analizará los nutrientes en segundos.', + demoModalBtn: 'Elegir Foto', + demoProcessing: 'Analizando alimentos...' + }, + howItWorks: { + title: 'Flujo sin fricción', + subtitle: 'Eliminamos la complejidad de rastrear tu dieta. Solo apunta y envía.', + step1Title: 'Captura Visual', + step1Desc: 'Toma una foto clara de tu plato. Nuestra IA acepta varios ángulos e identifica múltiples elementos.', + step2Title: 'Envío Instantáneo', + step2Desc: 'Comparte vía WhatsApp. Sin formularios, sin inicios de sesión complejos, sin barreras.', + step3Title: 'Análisis Detallado', + step3Desc: 'Recibe el informe nutricional completo y haz preguntas al asistente en tiempo real.' + }, + features: { + guruTitle: 'IA Avançada', + mainTitle: 'Tu nutricionista de bolsillo, 24/7.', + subtitle: 'No solo entregamos números. Nuestra IA entiende el contexto de tu dieta y ofrece feedback cualitativo.', + f1Title: 'Rayos-X Nutricional', + f1Desc: 'Identificación automática de ingredientes con desglose detallado de Proteínas, Carbohidratos, Grasas y Fibra.', + f2Title: 'Sugerencias de Mejora', + f2Desc: 'La IA no solo lee, opina. Recibe consejos como: "Añade hojas verdes para más saciedad".', + f3Title: 'Cambios Inteligentes', + f3Desc: '¿Amas comer bien? La IA sugiere sustituciones sabrosas para reducir calorías sin sacrificar el placer.', + f4Title: 'Volumetría Visual', + f4Desc: 'Estimación inteligente de peso basada en la proporción del plato. Jubila la báscula de cocina.', + f5Title: 'Consultoría Chat', + f5Desc: 'Pregunta: "¿Puedo comer esto antes de entrenar?" o "¿Cuál es la mejor opción de este menú?".', + visualTipTitle: 'Smart Insight', + visualTipDesc: '¡Gran elección de grasas! ¿Qué tal añadir semillas de calabaza para más crujido y zinc?' + }, + testimonials: { + title: 'Quien usa, aprueba', + subtitle: 'Únete a una comunidad enfocada en resultados reales.', + r1Content: 'La precisión de lectura de macros cambió mi juego. Ya no pierdo tiempo pesando comida.', + r1Role: 'Atleta de Crossfit', + r2Content: 'Lo indico a todos mis pacientes. La adhesión al plan aumentó en 40%.', + r2Role: 'Nutricionista Deportiva', + r3Content: 'Interfaz limpia, rápida y sin fricción. Exactamente lo que necesitaba.', + r3Role: 'Ingeniero de Software' + }, + pricing: { + title: 'Planes Pro', + subtitle: 'Elige la flexibilidad que tu estilo de vida exige.', + freeTierTitle: 'Empieza Gratis', + freeTierDesc: 'Todos los usuarios comienzan con 5 consultas gratis.', + secure: 'Pago seguro vía Stripe. Cancela cuando quieras.', + plans: { + monthly: { + title: 'Mensual', + price: '€ 14,90', + period: '/mes', + billingInfo: 'Cobrado mensualmente', + description: 'Flexibilidad total.', + btnText: 'Suscribir Mensual', + features: [ + 'Consultas Ilimitadas', + 'Chat con Nutricionista IA', + 'Historial Ilimitado', + 'Análisis de Micronutrientes' + ] + }, + quarterly: { + title: 'Trimestral', + price: '€ 11,90', + period: '/mes', + billingInfo: 'Cobrado cada 3 meses', + description: 'Compromiso a medio plazo.', + btnText: 'Elegir Trimestral', + features: [ + 'Todo lo del Mensual', + 'Soporte Prioritario', + 'Ahorra 20%', + 'Acceso a funciones Beta' + ] + }, + annual: { + title: 'Anual', + price: '€ 9,90', + period: '/mes', + billingInfo: 'Cobrado anualmente', + description: 'El favorito para resultados.', + btnText: 'Suscribir Anual', + highlight: 'Mejor Valor', + savings: 'Ahorra 40%', + features: [ + 'Todo lo del Trimestral', + 'Reportes de Evolución', + 'Soporte VIP', + 'Precio congelado por 1 año' + ] + } + } + }, + faq: { + title: 'Preguntas Frecuentes', + q1: '¿La información es 100% precisa?', + a1: 'No. Los análisis son estimaciones visuales. Factores como la preparación pueden variar. Úsalo como guía.', + q2: '¿Funciona con cualquier comida?', + a2: '¡Sí! Funciona con platos caseros, fiambreras, restaurantes y comida rápida.', + q3: '¿Necesito instalar una app?', + a3: 'No. Todo funciona directamente por WhatsApp.', + q4: '¿Puedo cancelar cuando quiera?', + a4: 'Sí, sin fidelidad. Cancela la suscripción Pro en cualquier momento.' + }, + faqPage: { + title: 'Centro de Ayuda', + subtitle: 'Encuentra respuestas a tus dudas sobre FoodSnap.ai', + searchPlaceholder: 'Busca tu duda (ej: cancelar, whatsapp...)', + backHome: 'Volver al Inicio', + categories: { + general: { + title: 'General', + items: [ + { q: '¿Qué es FoodSnap.ai?', a: 'FoodSnap.ai es un servicio de nutrición inteligente que usa inteligencia artificial para analizar fotos de tus comidas, calcular calorías/macros y dar feedback en tiempo real vía WhatsApp.' }, + { q: '¿Cómo funciona el análisis?', a: 'Solo envía una foto de tu plato a nuestro número de WhatsApp. Nuestra IA identifica los alimentos, estima porciones y devuelve un informe nutricional completo en segundos.' }, + { q: '¿Necesito una app?', a: 'No. Todo funciona dentro de WhatsApp. También tienes acceso a un panel web para ver tu historial completo y gráficos de evolución.' } + ] + }, + account: { + title: 'Mi Cuenta', + items: [ + { q: '¿Cómo cambio mi contraseña?', a: 'Puedes restablecer tu contraseña en la pantalla de inicio de sesión haciendo clic en "¿Olvidaste tu contraseña?" o dentro del panel de usuario en configuración.' }, + { q: '¿Puedo compartir mi cuenta?', a: 'La suscripción es individual. El historial y las recomendaciones están personalizados para un único perfil de usuario.' }, + { q: '¿Cómo cambio mi teléfono registrado?', a: 'Actualmente, para cambiar el teléfono vinculado a WhatsApp, contacta con soporte vía chat en el panel.' } + ] + }, + billing: { + title: 'Planes y Pagos', + items: [ + { q: '¿Métodos de pago?', a: 'Aceptamos tarjetas de crédito (Visa, Mastercard, Amex) a través de la plataforma segura Stripe.' }, + { q: '¿Hay permanencia?', a: 'El plan mensual no tiene permanencia. Los planes trimestrales y anuales tienen compromiso por el período contratado a cambio de un descuento.' }, + { q: '¿Cómo cancelo?', a: 'Accede al Panel de Usuario > Mi Suscripción > Portal de Cliente. Puedes cancelar la renovación automática allí en cualquier momento.' }, + { q: '¿Política de reembolso?', a: 'Sí, ofrecemos garantía incondicional de 7 días para nuevos suscriptores. Si no te gusta, devolvemos el 100%.' } + ] + }, + technical: { + title: 'Soporte Técnico', + items: [ + { q: 'El bot no responde en WhatsApp', a: 'Verifica si guardaste el número correctamente y si tu plan está activo. A veces puede haber una breve cola de procesamiento. Intenta enviar "Hola" para reiniciar.' }, + { q: 'La IA identificó mal mi plato', a: 'Aunque raro, pueden ocurrir imprecisiones. Intenta tomar fotos con buena iluminación donde todos los ingredientes sean visibles. Puedes enviar un texto corrigiendo (ej: "no es pollo, es pescado") y la IA ajustará.' } + ] + } + } + }, + footer: { + ctaTitle: '¿Listo para tomar el control?', + ctaDesc: 'Sin hojas de cálculo complejas, sin básculas. Solo tú, tu comida y la mejor tecnología de IA.', + ctaBtn: 'Acceder a FoodSnap', + desc: 'Inteligencia Artificial aplicada a la nutrición para simplificar la vida de quien busca salud.', + platform: 'Plataforma', + legal: 'Legal', + connect: 'Conectar', + rights: 'Todos los derechos reservados.' + }, + auth: { + welcomeBack: 'Bienvenido de nuevo', + createAccount: 'Crea tu cuenta', + completeProfile: 'Completa tu perfil', + accessPanel: 'Accede a tu panel e historial.', + fillToAccess: 'Rellena para acceder a la IA.', + confirmPhone: 'Confirma tu WhatsApp para recibir análisis.', + nameLabel: 'Nombre Completo', + phoneLabel: 'WhatsApp (con código país)', + emailLabel: 'Correo Electrónico', + passwordLabel: 'Contraseña', + phonePlaceholder: '34600123456', + phoneHelper: 'Solo números, incluye código de país.', + btnRegister: 'Crear Cuenta Gratis', + btnLogin: 'Entrar al Panel', + btnSave: 'Guardar y Continuar', + btnSuccess: '¡Éxito!', + googleBtn: 'Google', + or: 'O continúa con', + noAccount: '¿No tienes cuenta?', + hasAccount: '¿Ya tienes cuenta?', + registerLink: 'Regístrate', + loginLink: 'Inicia sesión', + security: 'Datos protegidos y encriptados.', + errorRequired: 'Todos los campos son obligatorios.', + errorPhone: 'WhatsApp inválido.', + successRegister: '¡Cuenta creada!', + successLogin: '¡Login exitoso!' + }, + dashboard: { + menuOverview: 'Visión General', + menuHistory: 'Historial', + menuSubscription: 'Suscripción', + logout: 'Cerrar sesión', + hello: 'Hola', + status: 'Estado', + statDishes: 'Platos Analisados', + statDishesSub: 'Total registrado', + statCals: 'Promedio Calorías', + statCalsSub: 'kcal/comida', + statPlan: 'Plan Actual', + activeSub: 'Suscripción Activa', + trialSub: 'Prueba Gratis', + upgradeSub: 'Mejora Disponible', + eatTitle: '¿Vas a comer algo ahora?', + eatDesc: 'Envía una foto a nuestro WhatsApp o súbela directamente aquí para registrar.', + btnHistory: 'Ver Historial', + btnWhatsapp: 'WhatsApp', + recentTitle: 'Recentes', + viewAll: 'Ver todo', + emptyRecent: 'Ningún plato registrado aún. ¡Toma una foto!', + historyTitle: 'Historial de Comidas', + historySubtitle: 'Todos tus análisis guardados automáticamente.', + searchPlaceholder: 'Buscar plato...', + emptyHistory: 'Aún no has enviado ninguna foto.', + subTitle: 'Gestionar Suscripción', + subDesc: 'Controla tus pagos y plan vía Stripe.', + currentPlan: 'Plan Actual', + validUntil: 'Válido hasta:', + limitedAccess: 'Acceso limitado al plan gratuito.', + portalText: '¿Deseas cambiar tu tarjeta de crédito o cancelar la suscripción?', + btnPortal: 'Abrir Portal de Cliente', + upgradeTitle: 'Mejora al PRO', + upgradeDesc: 'Desbloquea historial ilimitado, análisis detallados y soporte prioritario.', + btnUpgrade: 'Suscribir por € 9,90/mes', + btnUpgradeShort: 'Obtener Pro', + connectTitle: 'Conectar ahora', + connectDesc: 'Sigue los pasos para activar la IA en WhatsApp.', + step1: 'Escanea el Código QR', + step2: 'Envía "Hola"', + step3: 'Envía una foto de tu plato', + scanLabel: 'Escanea para iniciar' + }, + tools: { + title: 'Herramientas Gratuitas', + subtitle: 'Calculadoras esenciales para tu viaje.', + bmi: { + title: 'Calculadora de IMC', + desc: 'Descubre tu Índice de Massa Corporal.', + labelWeight: 'Peso (kg)', + labelHeight: 'Altura (cm)', + result: 'Tu IMC es' + }, + water: { + title: 'Hidratación Diaria', + desc: 'Descubre cuánta agua debes beber.', + result: 'Tu meta es', + daily: 'litros al día' + }, + bmr: { + title: 'Gasto Calórico (TMB)', + desc: 'Cuántas calorías quemas en reposo.', + labelAge: 'Edad', + labelGender: 'Género', + male: 'Hombre', + female: 'Mujer', + result: 'Tu TMB es' + }, + tdee: { + title: 'Gasto Total (TDEE)', + desc: 'Calorías diarias considerando actividad.', + activity: 'Nivel de Actividad', + sedentary: 'Sedentario', + light: 'Ligeramente Activo', + moderate: 'Moderadamente Activo', + active: 'Muy Activo', + veryActive: 'Atleta / Extremo', + result: 'Gasto Diario' + }, + orm: { + title: 'Fuerza Máxima (1RM)', + desc: 'Carga máxima teórica para 1 repetição.', + lift: 'Peso Levantado (kg)', + reps: 'Repeticiones', + result: 'Tu 1RM Estimado' + }, + bodyfat: { + title: 'Grasa Corporal', + desc: 'Estimación basada en el método de la Marina.', + waist: 'Cintura (cm)', + neck: 'Cuello (cm)', + hip: 'Cadera (cm)', + result: 'Grasa Estimada' + }, + hr: { + title: 'Zonas de FC', + desc: 'Frecuencia Cardíaca Máxima y Zonas.', + result: 'FC Máxima', + zone: 'Zona Quema Grasa' + }, + calculate: 'Calcular', + back: 'Volver' + }, + coach: { + title: 'Entrenador Personal IA', + subtitle: 'Genera tu protocolo ideal basado en tu biotipo.', + photosStep: { + alert: 'Fotos de Evaluación: Usa ropa ligera. Posiciona la cámara a la altura del pecho, cuerpo relajado.', + front: 'Frente', + side: 'Perfil', + back: 'Espalda', + camera: 'Cámara', + gallery: 'Galería' + }, + goalStep: { + title: '¿Cuál es tu objetivo principal?', + hypertrophy: { title: 'Hipertrofia', desc: 'Ganar masa muscular y volumen.' }, + definition: { title: 'Definición', desc: 'Quemar grasa y definir músculos.' }, + maintenance: { title: 'Salud y Mantenimiento', desc: 'Mejorar alimentación y energía.' }, + strength: { title: 'Fuerza Pura', desc: 'Enfocar en progresión de carga.' } + }, + processing: { + errorTitle: '¡Ups! Algo salió mal.', + retry: 'Intentar de Nuevo', + analyzing: 'Buscando biotipo...', + wait: 'La IA está procesando tus fotos. Esto puede tardar hasta 30 segundos.', + steps: [ + "Identificando Biotipo...", + "Analizando Postura y Simetría...", + "Calculando Estimación de Grasa...", + "Ajustando Macros para tu Objetivo...", + "Generando Rutina de Entrenamiento..." + ] + }, + buttons: { + next: 'Siguiente', + back: 'Volver', + generate: 'Generar Protocolo' + } + } + } +}; + +interface LanguageContextType { + language: Language; + setLanguage: (lang: Language) => void; + t: Translations; +} + +const LanguageContext = createContext(undefined); + +export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [language, setLanguage] = useState('pt'); + + return ( + + {children} + + ); +}; + +export const useLanguage = () => { + const context = useContext(LanguageContext); + if (!context) { + throw new Error('useLanguage must be used within a LanguageProvider'); + } + return context; +}; \ No newline at end of file diff --git a/src/hooks/useCoachPlan.ts b/src/hooks/useCoachPlan.ts new file mode 100644 index 0000000..98b7237 --- /dev/null +++ b/src/hooks/useCoachPlan.ts @@ -0,0 +1,52 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/lib/supabase'; + +export const useCoachPlan = (userId: string) => { + const [coachPlan, setCoachPlan] = useState(null); + const [coachHistory, setCoachHistory] = useState([]); + const [loadingCoachPlan, setLoadingCoachPlan] = useState(false); + + const fetchCoachPlan = async () => { + if (!userId) return; + + setLoadingCoachPlan(true); + try { + const { data, error } = await supabase + .from('coach_analyses') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error) { + console.error("Error fetching coach plan:", error); + return; + } + console.log("Coach Plan Data:", data); // DEBUG + + + if (data) { + setCoachHistory(data); + if (data.length > 0) { + // Set latest as default + const latest = data[0]; + const structured = typeof latest.ai_structured === 'string' + ? JSON.parse(latest.ai_structured) + : latest.ai_structured; + setCoachPlan(structured); + } else { + setCoachPlan(null); + } + } + } catch (err) { + console.error("Error fetching coach plan:", err); + } finally { + setLoadingCoachPlan(false); + } + }; + + useEffect(() => { + fetchCoachPlan(); + }, [userId]); + + return { coachPlan, setCoachPlan, coachHistory, loadingCoachPlan, refetchCoachPlan: fetchCoachPlan }; +}; diff --git a/src/hooks/useDashboardHistory.ts b/src/hooks/useDashboardHistory.ts new file mode 100644 index 0000000..42ed2a1 --- /dev/null +++ b/src/hooks/useDashboardHistory.ts @@ -0,0 +1,73 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/lib/supabase'; + +export const useDashboardHistory = (userId: string) => { + const [history, setHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + + const fetchHistory = async () => { + if (!userId) return; + + setLoadingHistory(true); + try { + const { data, error } = await supabase + .from('food_analyses') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(20); + + if (error) { + console.error("Error fetching history:", error); + setHistory([]); + return; + } + + if (data) { + const formatted = data.map((item: any) => { + // Parse do ai_structured para pegar os itens + let itemDetails = ''; + try { + // Verifica se é string antes de parsear, se já for objeto usa direto + const structured = typeof item.ai_structured === 'string' + ? JSON.parse(item.ai_structured) + : item.ai_structured; + + if (structured?.items && Array.isArray(structured.items)) { + itemDetails = structured.items.map((i: any) => i.name).join(', '); + } + } catch (e) { + console.log('Error parsing AI structure', e); + } + + // Construção da URL do Bucket + const bucketUrl = `https://mnhgpnqkwuqzpvfrwftp.supabase.co/storage/v1/object/public/consultas/${item.user_id}/${item.id}.jpg`; + + return { + id: item.id, + date: new Date(item.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }), + category: item.category || 'Refeição', + details: itemDetails, + score: item.nutrition_score || 0, + cals: Math.round(item.total_calories || 0), + protein: Math.round(item.total_protein || 0) + 'g', + carbs: Math.round(item.total_carbs || 0) + 'g', + fat: Math.round(item.total_fat || 0) + 'g', + img: bucketUrl + }; + }); + setHistory(formatted); + } + } catch (err) { + console.error("Error fetching history:", err); + } finally { + setLoadingHistory(false); + } + }; + + useEffect(() => { + fetchHistory(); + }, [userId]); + + return { history, loadingHistory, refetchHistory: fetchHistory }; +}; diff --git a/src/hooks/useDashboardStats.ts b/src/hooks/useDashboardStats.ts new file mode 100644 index 0000000..06ed0c1 --- /dev/null +++ b/src/hooks/useDashboardStats.ts @@ -0,0 +1,57 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/lib/supabase'; + +interface DashboardStats { + totalCount: number; + avgCals: number; +} + +export const useDashboardStats = (userId: string) => { + const [stats, setStats] = useState({ totalCount: 0, avgCals: 0 }); + const [loadingStats, setLoadingStats] = useState(false); + + const fetchStats = async () => { + if (!userId) return; + + setLoadingStats(true); + try { + // 1. Get Total Count + const { count, error: countError } = await supabase + .from('food_analyses') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId); + + if (countError) throw countError; + + // 2. Get Average Calories + const { data: calData, error: calError } = await supabase + .from('food_analyses') + .select('total_calories') + .eq('user_id', userId); + + if (calError) throw calError; + + let calculatedAvg = 0; + if (calData && calData.length > 0) { + const sum = calData.reduce((acc, curr) => acc + (curr.total_calories || 0), 0); + calculatedAvg = Math.round(sum / calData.length); + } + + setStats({ + totalCount: count || 0, + avgCals: calculatedAvg + }); + + } catch (err) { + console.error("Error fetching stats:", err); + } finally { + setLoadingStats(false); + } + }; + + useEffect(() => { + fetchStats(); + }, [userId]); + + return { stats, loadingStats, refetchStats: fetchStats }; +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..7edb9cc --- /dev/null +++ b/src/index.css @@ -0,0 +1,113 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Base Styles */ +:root { + --brand-primary: #059669; +} + +html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: 'Plus Jakarta Sans', 'Inter', sans-serif; + background-color: #f8fafc; + /* Lighter, cleaner background */ +} + +/* Premium Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* Glassmorphism Utilities */ +.glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.5); +} + +.glass-dark { + background: rgba(17, 24, 39, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fadeIn 0.4s ease-out forwards; +} + +/* Typography Enhancements */ +h1, +h2, +h3, +h4, +h5, +h6 { + letter-spacing: -0.02em; + /* Tight tracking for headings */ +} + +/* Selection */ +::selection { + background: rgba(16, 185, 129, 0.2); + color: #064e3b; +} + +/* Utilities not in Tailwind default config */ +.text-balance { + text-wrap: balance; +} + +/* Premium Shadows & Depth */ +.shadow-premium { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 10px 15px -3px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.02); +} + +.shadow-glow { + box-shadow: 0 0 20px rgba(5, 150, 105, 0.15); +} + +.shadow-card-hover { + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.01); +} + +/* Subtle Texture */ +.bg-noise { + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.03'/%3E%3C/svg%3E"); +} \ No newline at end of file diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts new file mode 100644 index 0000000..a977f76 --- /dev/null +++ b/src/lib/database.types.ts @@ -0,0 +1,651 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export interface Database { + public: { + Tables: { + app_settings: { + Row: { + key: string + value: string + created_at: string + updated_at: string + } + Insert: { + key: string + value: string + created_at?: string + updated_at?: string + } + Update: { + key?: string + value?: string + created_at?: string + updated_at?: string + } + Relationships: [] + } + coupons: { + Row: { + id: string + code: string + discount_percent: number + max_uses: number | null + uses_count: number | null + is_active: boolean | null + valid_until: string | null + created_at: string | null + } + Insert: { + id?: string + code: string + discount_percent: number + max_uses?: number | null + uses_count?: number | null + is_active?: boolean | null + valid_until?: string | null + created_at?: string | null + } + Update: { + id?: string + code?: string + discount_percent?: number + max_uses?: number | null + uses_count?: number | null + is_active?: boolean | null + valid_until?: string | null + created_at?: string | null + } + Relationships: [] + } + food_analyses: { + Row: { + id: string + user_id: string + source: string + image_url: string | null + ai_raw_response: string + ai_structured: Json + total_calories: number | null + total_protein: number | null + total_carbs: number | null + total_fat: number | null + total_fiber: number | null + total_sodium_mg: number | null + nutrition_score: number | null + confidence_level: string | null + used_free_quota: boolean | null + created_at: string | null + source_message_id: string | null + } + Insert: { + id?: string + user_id: string + source?: string + image_url?: string | null + ai_raw_response: string + ai_structured: Json + total_calories?: number | null + total_protein?: number | null + total_carbs?: number | null + total_fat?: number | null + total_fiber?: number | null + total_sodium_mg?: number | null + nutrition_score?: number | null + confidence_level?: string | null + used_free_quota?: boolean | null + created_at?: string | null + source_message_id?: string | null + } + Update: { + id?: string + user_id?: string + source?: string + image_url?: string | null + ai_raw_response?: string + ai_structured?: Json + total_calories?: number | null + total_protein?: number | null + total_carbs?: number | null + total_fat?: number | null + total_fiber?: number | null + total_sodium_mg?: number | null + nutrition_score?: number | null + confidence_level?: string | null + used_free_quota?: boolean | null + created_at?: string | null + source_message_id?: string | null + } + Relationships: [ + { + foreignKeyName: "food_analyses_user_id_fkey" + columns: ["user_id"] + referencedRelation: "users" // implied, usually auth.users but referenced as generic + referencedColumns: ["id"] + } + ] + } + food_analysis_items: { + Row: { + id: string + analysis_id: string + user_id: string + name: string | null + portion: string | null + calories: number | null + protein: number | null + carbs: number | null + fat: number | null + fiber: number | null + sugar: number | null + sodium_mg: number | null + flags: Json | null + created_at: string | null + } + Insert: { + id?: string + analysis_id: string + user_id: string + name?: string | null + portion?: string | null + calories?: number | null + protein?: number | null + carbs?: number | null + fat?: number | null + fiber?: number | null + sugar?: number | null + sodium_mg?: number | null + flags?: Json | null + created_at?: string | null + } + Update: { + id?: string + analysis_id?: string + user_id?: string + name?: string | null + portion?: string | null + calories?: number | null + protein?: number | null + carbs?: number | null + fat?: number | null + fiber?: number | null + sugar?: number | null + sodium_mg?: number | null + flags?: Json | null + created_at?: string | null + } + Relationships: [ + { + foreignKeyName: "food_analysis_items_analysis_id_fkey" + columns: ["analysis_id"] + referencedRelation: "food_analyses" + referencedColumns: ["id"] + }, + { + foreignKeyName: "food_analysis_items_user_id_fkey" + columns: ["user_id"] + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } + payments: { + Row: { + id: string + user_id: string | null + amount_cents: number + currency: string | null + status: string | null + plan_type: string | null + stripe_payment_id: string | null + created_at: string | null + } + Insert: { + id?: string + user_id?: string | null + amount_cents: number + currency?: string | null + status?: string | null + plan_type?: string | null + stripe_payment_id?: string | null + created_at?: string | null + } + Update: { + id?: string + user_id?: string | null + amount_cents?: number + currency?: string | null + status?: string | null + plan_type?: string | null + stripe_payment_id?: string | null + created_at?: string | null + } + Relationships: [ + { + foreignKeyName: "payments_user_id_fkey" + columns: ["user_id"] + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } + pro_assessments: { + Row: { + id: string + professional_id: string + student_id: string + date: string | null + weight: number | null + height: number | null + age: number | null + bf_percent: number | null + muscle_percent: number | null + bmi: number | null + measurements: Json | null + methodology: Json | null + photos: string[] | null + created_at: string + } + Insert: { + id?: string + professional_id: string + student_id: string + date?: string | null + weight?: number | null + height?: number | null + age?: number | null + bf_percent?: number | null + muscle_percent?: number | null + bmi?: number | null + measurements?: Json | null + methodology?: Json | null + photos?: string[] | null + created_at?: string + } + Update: { + id?: string + professional_id?: string + student_id?: string + date?: string | null + weight?: number | null + height?: number | null + age?: number | null + bf_percent?: number | null + muscle_percent?: number | null + bmi?: number | null + measurements?: Json | null + methodology?: Json | null + photos?: string[] | null + created_at?: string + } + Relationships: [ + { + foreignKeyName: "pro_assessments_professional_id_fkey" + columns: ["professional_id"] + referencedRelation: "professionals" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pro_assessments_student_id_fkey" + columns: ["student_id"] + referencedRelation: "pro_students" + referencedColumns: ["id"] + } + ] + } + pro_assignments: { + Row: { + id: string + professional_id: string + student_id: string + workout_id: string + start_date: string | null + end_date: string | null + notes: string | null + created_at: string + } + Insert: { + id?: string + professional_id: string + student_id: string + workout_id: string + start_date?: string | null + end_date?: string | null + notes?: string | null + created_at?: string + } + Update: { + id?: string + professional_id?: string + student_id?: string + workout_id?: string + start_date?: string | null + end_date?: string | null + notes?: string | null + created_at?: string + } + Relationships: [ + { + foreignKeyName: "pro_assignments_professional_id_fkey" + columns: ["professional_id"] + referencedRelation: "professionals" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pro_assignments_student_id_fkey" + columns: ["student_id"] + referencedRelation: "pro_students" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pro_assignments_workout_id_fkey" + columns: ["workout_id"] + referencedRelation: "pro_workouts" + referencedColumns: ["id"] + } + ] + } + pro_students: { + Row: { + id: string + professional_id: string + name: string + email: string | null + phone: string | null + status: 'active' | 'inactive' | 'pending' | null + linked_user_id: string | null + goals: string | null + notes: string | null + created_at: string + updated_at: string + } + Insert: { + id?: string + professional_id: string + name: string + email?: string | null + phone?: string | null + status?: 'active' | 'inactive' | 'pending' | null + linked_user_id?: string | null + goals?: string | null + notes?: string | null + created_at?: string + updated_at?: string + } + Update: { + id?: string + professional_id?: string + name?: string + email?: string | null + phone?: string | null + status?: 'active' | 'inactive' | 'pending' | null + linked_user_id?: string | null + goals?: string | null + notes?: string | null + created_at?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "pro_students_professional_id_fkey" + columns: ["professional_id"] + referencedRelation: "professionals" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pro_students_linked_user_id_fkey" + columns: ["linked_user_id"] + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } + pro_workouts: { + Row: { + id: string + professional_id: string + title: string + description: string | null + difficulty: 'beginner' | 'intermediate' | 'advanced' | null + exercises: Json | null + tags: string[] | null + created_at: string + } + Insert: { + id?: string + professional_id: string + title: string + description?: string | null + difficulty?: 'beginner' | 'intermediate' | 'advanced' | null + exercises?: Json | null + tags?: string[] | null + created_at?: string + } + Update: { + id?: string + professional_id?: string + title?: string + description?: string | null + difficulty?: 'beginner' | 'intermediate' | 'advanced' | null + exercises?: Json | null + tags?: string[] | null + created_at?: string + } + Relationships: [ + { + foreignKeyName: "pro_workouts_professional_id_fkey" + columns: ["professional_id"] + referencedRelation: "professionals" + referencedColumns: ["id"] + } + ] + } + professionals: { + Row: { + id: string + business_name: string | null + cref_crn: string | null + bio: string | null + specialties: string[] | null + logo_url: string | null + primary_color: string | null + contacts: Json | null + created_at: string + updated_at: string + } + Insert: { + id: string + business_name?: string | null + cref_crn?: string | null + bio?: string | null + specialties?: string[] | null + logo_url?: string | null + primary_color?: string | null + contacts?: Json | null + created_at?: string + updated_at?: string + } + Update: { + id?: string + business_name?: string | null + cref_crn?: string | null + bio?: string | null + specialties?: string[] | null + logo_url?: string | null + primary_color?: string | null + contacts?: Json | null + created_at?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "professionals_id_fkey" + columns: ["id"] + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } + profiles: { + Row: { + id: string + full_name: string + email: string + phone: string | null + created_at: string | null + updated_at: string | null + public_id: string | null + phone_e164: string | null + is_admin: boolean | null + is_professional: boolean | null + avatar_url: string | null + } + Insert: { + id: string + full_name: string + email: string + phone?: string | null + created_at?: string | null + updated_at?: string | null + public_id?: string | null + phone_e164?: string | null + is_admin?: boolean | null + is_professional?: boolean | null + avatar_url?: string | null + } + Update: { + id?: string + full_name?: string + email?: string + phone?: string | null + created_at?: string | null + updated_at?: string | null + public_id?: string | null + phone_e164?: string | null + is_admin?: boolean | null + is_professional?: boolean | null + avatar_url?: string | null + } + Relationships: [ + { + foreignKeyName: "profiles_id_fkey" + columns: ["id"] + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } + stripe_customers: { + Row: { + user_id: string + stripe_customer_id: string + email: string | null + created_at: string + updated_at: string + } + Insert: { + user_id: string + stripe_customer_id: string + email?: string | null + created_at?: string + updated_at?: string + } + Update: { + user_id?: string + stripe_customer_id?: string + email?: string | null + created_at?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "stripe_customers_pkey" // It's actually a PK but often a FK too + columns: ["user_id"] + referencedRelation: "users" // implicit + referencedColumns: ["id"] + } + ] + } + stripe_events: { + Row: { + id: string + type: string | null + created_at: string + } + Insert: { + id: string + type?: string | null + created_at?: string + } + Update: { + id?: string + type?: string | null + created_at?: string + } + Relationships: [] + } + user_entitlements: { + Row: { + user_id: string + entitlement_code: string + is_trial: boolean + is_active: boolean + valid_until: string | null + usage: Json + created_at: string + updated_at: string + plan_type: string | null + } + Insert: { + user_id: string + entitlement_code: string + is_trial?: boolean + is_active?: boolean + valid_until?: string | null + usage?: Json + created_at?: string + updated_at?: string + plan_type?: string | null + } + Update: { + user_id?: string + entitlement_code?: string + is_trial?: boolean + is_active?: boolean + valid_until?: string | null + usage?: Json + created_at?: string + updated_at?: string + plan_type?: string | null + } + Relationships: [ + { + foreignKeyName: "user_entitlements_pkey" + columns: ["user_id"] + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } + } + Views: { + user_access_summary: { + Row: { + user_id: string | null + free_used: number | null + free_remaining: number | null + plan_active: boolean | null + plan_code: string | null + plan_started_at: string | null + plan_valid_until: string | null + can_use_paid: boolean | null + } + } + } + } +} diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts new file mode 100644 index 0000000..60cdc9b --- /dev/null +++ b/src/lib/gemini.ts @@ -0,0 +1,112 @@ +import { GoogleGenAI } from "@google/genai"; + +const SYSTEM_PROMPT = ` +Você é o FoodSnap.ai, um nutricionista comportamental e científico. +Analise a imagem enviada e retorne um JSON puro (sem markdown) seguindo estritamente este schema: + +{ + "items": [ + { + "name": "Nome do alimento", + "portion": "Quantidade estimada (ex: 150g, 1 unidade)", + "calories": 0, + "protein": 0, + "carbs": 0, + "fat": 0, + "fiber": 0, + "sugar": 0, + "sodium_mg": 0, + "flags": ["fritura", "processado", "saudavel", "alto_acucar"] + } + ], + "total": { + "calories": 0, + "protein": 0, + "carbs": 0, + "fat": 0, + "fiber": 0, + "sugar": 0, + "sodium_mg": 0 + }, + "category": "Café da Manhã" | "Almoço" | "Jantar" | "Lanche" | "Pré-Treino" | "Pós-Treino", + "health_score": 0, + "confidence": "alta" | "media" | "baixa", + "tip": { + "title": "Titulo curto", + "text": "Dica prática e motivadora de até 2 frases sobre a refeição.", + "reason": "Explicação científica curta" + } +} + +Regras: +1. Health Score de 0 a 100. Considere densidade nutritiva, não apenas calorias. +2. Se não identificar comida, retorne lista de itens vazia e confidence "baixa". +`; + +export interface AnalysisResult { + items: { + name: string; + portion: string; + calories: number; + protein: number; + carbs: number; + fat: number; + fiber: number; + sugar: number; + sodium_mg: number; + flags: string[]; + }[]; + total: { + calories: number; + protein: number; + carbs: number; + fat: number; + fiber: number; + sugar: number; + sodium_mg: number; + }; + category: string; + health_score: number; + confidence: 'alta' | 'media' | 'baixa'; + tip: { + title: string; + text: string; + reason: string; + }; +} + +export const analyzeImage = async (base64Image: string, mimeType: string = 'image/jpeg'): Promise => { + const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); + + try { + const response = await ai.models.generateContent({ + model: 'gemini-2.5-flash', + contents: { + parts: [ + { + inlineData: { + mimeType: mimeType, + data: base64Image + } + }, + { + text: SYSTEM_PROMPT + } + ] + }, + config: { + responseMimeType: 'application/json', + temperature: 0.1 + } + }); + + if (response.text) { + return JSON.parse(response.text) as AnalysisResult; + } + + throw new Error("Resposta vazia da IA"); + } catch (error) { + console.error("Erro na análise Gemini:", error); + throw error; + } +}; \ No newline at end of file diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..2f4d946 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,8 @@ +import { createClient } from "@supabase/supabase-js"; +import { Database } from "./database.types"; + +// Credentials provided in the prompt +const SUPABASE_URL = "https://mnhgpnqkwuqzpvfrwftp.supabase.co"; +const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1uaGdwbnFrd3VxenB2ZnJ3ZnRwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjU3MTk4NTUsImV4cCI6MjA4MTI5NTg1NX0.DBYmhgiZoCmA0AlejJRsTh85HxRDEnG_ihkEQ2cXcpk"; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..30597ce --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; + +const container = document.getElementById('root'); + +if (container) { + const root = createRoot(container); + root.render( + + + + ); +} else { + console.error("FATAL: Elemento root não encontrado no HTML."); +} \ No newline at end of file diff --git a/src/n8n-coach-whatsapp.json b/src/n8n-coach-whatsapp.json new file mode 100644 index 0000000..dc3e5a3 --- /dev/null +++ b/src/n8n-coach-whatsapp.json @@ -0,0 +1,572 @@ +{ + "name": "FoodSnap - Coach AI (WhatsApp)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "wa/coach-inbound", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -500, + -940 + ], + "id": "webhook-coach", + "name": "Webhook (Whatsapp)" + }, + { + "parameters": { + "jsCode": "const body = $json.body ?? $json;\nconst data = body.data ?? {};\n\nconst remoteJid = data?.key?.remoteJid?.includes('@s.whatsapp.net') ? data.key.remoteJid : data?.key?.remoteJidAlt || '';\nconst number = remoteJid.replace(/\\D/g, '');\nconst message_id = data?.key?.id || '';\nconst text = data?.message?.conversation || data?.message?.extendedTextMessage?.text || '';\n\n// Check for image\nconst imageMessage = data?.message?.imageMessage || data?.message?.extendedTextMessage?.contextInfo?.quotedMessage?.imageMessage || null;\n\nreturn [{\n number,\n remoteJid,\n message_id,\n text,\n hasImage: !!imageMessage,\n imageMessage,\n username: data?.pushName || 'Atleta'\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -280, + -940 + ], + "id": "normalize-inbound", + "name": "Normalizar Dados" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "select * from check_access_by_whatsapp('{{ $json.number }}')", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + -60, + -940 + ], + "id": "validate-user", + "name": "Validar Usuario (RPC)", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "check-user", + "leftValue": "={{ $json.exists }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 160, + -940 + ], + "id": "if-exists", + "name": "Usuario Existe?" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "check-process", + "leftValue": "={{ $json.can_process }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 400, + -1040 + ], + "id": "if-quota", + "name": "Tem Quota/Plano?" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "check-image", + "leftValue": "={{ $node[\"Normalizar Dados\"].json.hasImage }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 640, + -940 + ], + "id": "if-image", + "name": "Tem Imagem?" + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "💪 *Coach AI*: Olá! Envie uma foto do seu corpo (preferencialmente de frente, roupa de treino) para eu fazer uma análise rápida do seu biótipo e sugestão de treino.", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 860, + -840 + ], + "id": "msg-intro", + "name": "Msg Intro", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "🧐 Analisando seu físico... Um momento!", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 860, + -1040 + ], + "id": "msg-ack", + "name": "Msg Ack", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "chat-api", + "operation": "get-media-base64", + "instanceName": "FoodSnap", + "messageId": "={{ $('Normalizar Dados').item.json.message_id }}" + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 1080, + -1040 + ], + "id": "get-image", + "name": "Baixar Imagem", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "operation": "toBinary", + "sourceProperty": "data.base64", + "options": {} + }, + "type": "n8n-nodes-base.convertToFile", + "typeVersion": 1.1, + "position": [ + 1300, + -1040 + ], + "id": "convert-binary", + "name": "Converter Binario" + }, + { + "parameters": { + "resource": "image", + "operation": "analyze", + "modelId": { + "__rl": true, + "value": "models/gemini-pro-vision", + "mode": "list", + "cachedResultName": "models/gemini-pro-vision" + }, + "text": "=Você é um Treinador Físico de Elite e Nutricionista Esportivo.\nAnalise a imagem fornecida (foto de corpo inteiro/físico).\n\n1. Verifique se é uma foto de corpo humano válida para análise fitness. Se não, retorne \"valid_body\": false.\n2. Se for válida, estime:\n - Biótipo Predominante (Ectomorfo, Mesomorfo, Endomorfo)\n - Percentual de Gordura (BF% aproximado)\n - Nível de Massa Muscular (Baixo, Médio, Alto)\n3. Sugira:\n - Objetivo Principal (Cutting, Bulking, Manutenção)\n - Divisão de Treino Recomendada (ex: ABC, ABCDE, Upper/Lower)\n - Foco Dietético (ex: Carb Cycling, Alto Carbo, Keto)\n - Dica de Ouro (uma frase curta de impacto)\n\nResponda APENAS em JSON estrito (sem markdown):\n{\n \"valid_body\": true,\n \"biotype\": \"...\",\n \"estimated_body_fat\": 15,\n \"muscle_mass\": \"Médio\",\n \"goal\": \"Bulking\",\n \"workout\": \"...\",\n \"diet\": \"...\",\n \"tip\": \"...\"\n}", + "inputType": "binary", + "simplify": false, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.googleGemini", + "typeVersion": 1, + "position": [ + 1520, + -1100 + ], + "id": "analyze-gemini", + "name": "Gemini Coach Analysis", + "credentials": { + "googlePalmApi": { + "id": "T2uIVBcjJ9h8BFCC", + "name": "Backup APIKEY" + } + } + }, + { + "parameters": { + "jsCode": "const raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\nconst clean = raw.replace(/```json/g, '').replace(/```/g, '').trim();\nlet data = {};\ntry { \n data = JSON.parse(clean); \n} catch(e) {\n data = { valid_body: false, error: 'Failed to parse AI' };\n}\n\nreturn [{ ...data }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1740, + -1100 + ], + "id": "parse-response", + "name": "Parse AI JSON" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "check-validity", + "leftValue": "={{ $json.valid_body }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 1960, + -1100 + ], + "id": "if-valid-body", + "name": "Corpo Valido?" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "insert into public.coach_analyses\n(\n user_id,\n source,\n image_url,\n ai_raw_response,\n ai_structured,\n biotype,\n estimated_body_fat,\n goal_suggestion,\n muscle_mass_level,\n used_free_quota\n)\nvalues\n(\n cast('{{ $(\"Validar Usuario & Quota\").item.json.user_id }}' as uuid),\n 'whatsapp',\n null,\n '{{ JSON.stringify($json) }}',\n '{{ JSON.stringify($json) }}',\n '{{ $json.biotype }}',\n cast({{ $json.estimated_body_fat || 0 }} as numeric),\n '{{ $json.goal }}',\n '{{ $json.muscle_mass }}',\n CASE\n WHEN {{ $(\"Validar Usuario & Quota\").item.json.plan_active }} = true THEN false\n ELSE true\n END\n)\nreturning id as analysis_id;", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + 2200, + -1180 + ], + "id": "save-db", + "name": "Salvar DB", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "=⚡ *Análise Coach AI*\n\n🧬 *Biótipo*: {{$json.biotype}}\n⚖️ *Gordura (BF)*: ~{{$json.estimated_body_fat}}%\n💪 *Massa Muscular*: {{$json.muscle_mass}}\n\n🎯 *Objetivo Sugerido*: {{$json.goal}}\n🏋️ *Treino*: {{$json.workout}}\n🥗 *Dieta*: {{$json.diet}}\n\n💡 *Dica*: {{$json.tip}}\n\n_Para ver o plano completo, acesse o App!_", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 2420, + -1180 + ], + "id": "reply-success", + "name": "Responder Resultado", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "⚠️ Não consegui identificar um físico claro nesta foto. Tente enviar uma foto de corpo inteiro ou tronco, com boa iluminação.", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 2200, + -980 + ], + "id": "reply-invalid", + "name": "Responder Invalido", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "🚫 *Limite do Coach Atingido*\n\nVocê já usou suas 3 análises de Coach gratuitas. Assine o plano PRO para avaliações ilimitadas! 🚀", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 640, + -1140 + ], + "id": "reply-limit", + "name": "Msg Limite", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + } + ], + "connections": { + "Webhook (Whatsapp)": { + "main": [ + [ + { + "node": "Normalizar Dados", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar Dados": { + "main": [ + [ + { + "node": "Validar Usuario & Quota", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar Usuario & Quota": { + "main": [ + [ + { + "node": "Usuario Existe?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Usuario Existe?": { + "main": [ + [ + { + "node": "Tem Quota/Plano?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Tem Quota/Plano?": { + "main": [ + [ + { + "node": "Tem Imagem?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Msg Limite", + "type": "main", + "index": 0 + } + ] + ] + }, + "Tem Imagem?": { + "main": [ + [ + { + "node": "Msg Ack", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Msg Intro", + "type": "main", + "index": 0 + } + ] + ] + }, + "Msg Ack": { + "main": [ + [ + { + "node": "Baixar Imagem", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baixar Imagem": { + "main": [ + [ + { + "node": "Converter Binario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Converter Binario": { + "main": [ + [ + { + "node": "Gemini Coach Analysis", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gemini Coach Analysis": { + "main": [ + [ + { + "node": "Parse AI JSON", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse AI JSON": { + "main": [ + [ + { + "node": "Corpo Valido?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Corpo Valido?": { + "main": [ + [ + { + "node": "Salvar DB", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Responder Invalido", + "type": "main", + "index": 0 + } + ] + ] + }, + "Salvar DB": { + "main": [ + [ + { + "node": "Responder Resultado", + "type": "main", + "index": 0 + } + ] + ] + } + } +} \ No newline at end of file diff --git a/src/n8n-daily-report.json b/src/n8n-daily-report.json new file mode 100644 index 0000000..e9d3a72 --- /dev/null +++ b/src/n8n-daily-report.json @@ -0,0 +1,199 @@ +{ + "name": "FoodSnap - Daily Report (Cron)", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 20 * * *" + } + ] + } + }, + "type": "n8n-nodes-base.schedule", + "typeVersion": 1.1, + "position": [ + -300, + -740 + ], + "id": "schedule-trigger", + "name": "Every Day 20h" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT \n p.id as user_id,\n p.phone_e164\nFROM public.profiles p\nJOIN public.food_analyses f ON f.user_id = p.id\nWHERE f.created_at >= CURRENT_DATE\nGROUP BY p.id, p.phone_e164;", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + -80, + -740 + ], + "id": "get-active-users", + "name": "Get Users Active Today", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "batchSize": 1, + "options": {} + }, + "type": "n8n-nodes-base.splitInBatches", + "typeVersion": 3, + "position": [ + 140, + -740 + ], + "id": "split-users", + "name": "Split Users" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT \n SUM(total_calories) as total_cals, \n SUM(total_protein) as total_prot,\n SUM(total_carbs) as total_carbs, \n SUM(total_fat) as total_fat,\n COUNT(*) as meal_count,\n AVG(nutrition_score)::numeric(10,1) as avg_score\nFROM public.food_analyses \nWHERE user_id = '{{ $json.user_id }}'::uuid \nAND created_at >= CURRENT_DATE;", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + 360, + -740 + ], + "id": "get-daily-stats", + "name": "Get Daily Stats", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "modelId": { + "__rl": true, + "value": "models/gemini-pro", + "mode": "list", + "cachedResultName": "models/gemini-pro" + }, + "promptType": "define", + "text": "=Você é um Nutricionista IA do FoodSnap.\n\nDados do Usuário Hoje:\n- Refeições: {{ $json.meal_count }}\n- Calorias Totais: {{ $json.total_cals }} kcal\n- Proteínas: {{ $json.total_prot }}g\n- Carbos: {{ $json.total_carbs }}g\n- Gorduras: {{ $json.total_fat }}g\n- Score Médio (0-100): {{ $json.avg_score }}\n\nCrie uma mensagem curta (máx 3 linhas) para o WhatsApp.\n1. Elogie se bateu meta (assuma 2000kcal base se não tiver dado).\n2. Dê uma dica rápida para amanhã baseada nos macros.\n3. Termine motivacional.\n4. Use emojis.\n5. Não use markdown bold (*) excessivamente, só em palavras chave.\n\nExemplo:\n\"Olá! Hoje você mandou bem nas proteínas (120g)! 💪 Amanhã tente reduzir um pouco a gordura no jantar. Continue assim! 🚀\"", + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.googleGemini", + "typeVersion": 1, + "position": [ + 580, + -740 + ], + "id": "generate-insight", + "name": "Generate Insight", + "credentials": { + "googlePalmApi": { + "id": "T2uIVBcjJ9h8BFCC", + "name": "Backup APIKEY" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Split Users').item.json.phone_e164.replace('+', '') }}", + "messageText": "=📊 *FoodSnap Diário*\n\nHoje você registrou {{ $('Get Daily Stats').item.json.meal_count }} refeições.\n🔥 *{{ $('Get Daily Stats').item.json.total_cals }} kcal* | 🥩 {{ $('Get Daily Stats').item.json.total_prot }}g Prot\n\n{{ $json.text }}\n\n_Até amanhã!_", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 800, + -740 + ], + "id": "send-whatsapp", + "name": "Enviar WhatsApp", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + } + ], + "connections": { + "Every Day 20h": { + "main": [ + [ + { + "node": "Get Users Active Today", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Users Active Today": { + "main": [ + [ + { + "node": "Split Users", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Users": { + "main": [ + [ + { + "node": "Get Daily Stats", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Daily Stats": { + "main": [ + [ + { + "node": "Generate Insight", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generate Insight": { + "main": [ + [ + { + "node": "Enviar WhatsApp", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enviar WhatsApp": { + "main": [ + [ + { + "node": "Split Users", + "type": "main", + "index": 0 + } + ] + ] + } + } +} \ No newline at end of file diff --git a/src/n8n-foodsnap-branched.json b/src/n8n-foodsnap-branched.json new file mode 100644 index 0000000..213042a --- /dev/null +++ b/src/n8n-foodsnap-branched.json @@ -0,0 +1,911 @@ +{ + "name": "FoodSnap - Switch (Food & Coach)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "wa/inbound", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -740, + -940 + ], + "id": "f33b8fb6-babb-4beb-ab36-ec6a25f14eb2", + "name": "Requisicao - Whatsapp", + "webhookId": "2179d0c4-aaf5-4ce4-9463-332f09919612" + }, + { + "parameters": { + "jsCode": "const body = $json.body ?? $json;\nconst data = body.data ?? {};\n\nconst remoteJid = data?.key?.remoteJid?.includes('@s.whatsapp.net') ? data.key.remoteJid : data?.key?.remoteJidAlt || '';\nconst number = remoteJid.replace(/\\D/g, '');\nconst message_id = data?.key?.id || '';\n\n// Texto (incluindo legenda de imagem)\nconst text = data?.message?.conversation || data?.message?.extendedTextMessage?.text || data?.message?.imageMessage?.caption || '';\n\nconst imageMessage = data?.message?.imageMessage || data?.message?.extendedTextMessage?.contextInfo?.quotedMessage?.imageMessage || null;\n\nreturn [{\n number,\n remoteJid,\n message_id,\n text,\n hasImage: !!imageMessage,\n imageMessage,\n pushName: data?.pushName || 'Usuário',\n timestamp: new Date().toISOString(),\n raw: body\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -520, + -940 + ], + "id": "32f29e03-c120-4425-b8da-5f9984503e63", + "name": "NormalizeInbound" + }, + { + "parameters": { + "dataType": "string", + "value1": "={{ $json.text }}", + "rules": { + "rules": [ + { + "operation": "contains", + "value2": "coach", + "output": 1 + }, + { + "operation": "contains", + "value2": "treino", + "output": 1 + }, + { + "operation": "contains", + "value2": "biotipo", + "output": 1 + }, + { + "operation": "contains", + "value2": "shape", + "output": 1 + } + ] + }, + "fallbackOutput": 0 + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 1, + "position": [ + -300, + -940 + ], + "id": "switch-router", + "name": "Roteador (Food/Coach)" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "with u as ( select id from public.profiles where phone_e164 = cast({{ $('NormalizeInbound').item.json.number }} as text) limit 1 ), ent as ( select ue.user_id, ue.is_active, ue.entitlement_code, ue.valid_until from public.user_entitlements ue where ue.user_id = (select id from u) order by ue.valid_until desc nulls last limit 1 ), usage as ( select (select id from u) as user_id, count(*) filter (where fa.used_free_quota = true) as free_used from public.food_analyses fa where fa.user_id = (select id from u) ) select (select id from u) is not null as exists, (select id from u) as user_id, coalesce((select free_used from usage), 0)::int as free_used, greatest(0, 5 - coalesce((select free_used from usage), 0))::int as free_remaining, coalesce((select is_active from ent), false) as plan_active, ( (select id from u) is not null and ( ( coalesce((select is_active from ent), false) and ( (select valid_until from ent) is null or (select valid_until from ent) > now() ) ) or greatest(0, 5 - coalesce((select free_used from usage), 0)) > 0 ) ) as can_process;", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + -60, + -1040 + ], + "id": "a329a262-e03c-41f2-9d96-5d37ed5f6159", + "name": "Validar Usuario (Food)", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "with u as ( select id from public.profiles where phone_e164 = cast({{ $('NormalizeInbound').item.json.number }} as text) limit 1 ), ent as ( select ue.is_active, ue.entitlement_code, ue.valid_until from public.user_entitlements ue where ue.user_id = (select id from u) order by ue.valid_until desc nulls last limit 1 ), usage as ( select count(*) as used_count from public.coach_analyses fa where fa.user_id = (select id from u) and fa.used_free_quota = true ) select (select id from u) as user_id, (select id from u) is not null as exists, coalesce((select used_count from usage), 0)::int as free_used, greatest(0, 3 - coalesce((select used_count from usage), 0))::int as free_remaining, coalesce((select is_active from ent), false) as plan_active, (coalesce((select is_active from ent), false) = true OR greatest(0, 3 - coalesce((select used_count from usage), 0)) > 0) as can_process", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + -60, + -700 + ], + "id": "validate-coach", + "name": "Validar Coach", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "check-process-coach", + "leftValue": "={{ $json.can_process }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 180, + -700 + ], + "id": "if-coach-quota", + "name": "Pode usar Coach?" + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('NormalizeInbound').item.json.number }}", + "messageText": "🏋️ *Coach AI*: Analisando seu físico... Aguarde!", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 420, + -700 + ], + "id": "msg-ack-coach", + "name": "Ack Coach", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "chat-api", + "operation": "get-media-base64", + "instanceName": "FoodSnap", + "messageId": "={{ $('NormalizeInbound').item.json.message_id }}" + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 640, + -700 + ], + "id": "get-img-coach", + "name": "Baixar IMG Coach", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "operation": "toBinary", + "sourceProperty": "data.base64", + "options": {} + }, + "type": "n8n-nodes-base.convertToFile", + "typeVersion": 1.1, + "position": [ + 860, + -700 + ], + "id": "bin-coach", + "name": "Binary Coach" + }, + { + "parameters": { + "resource": "image", + "operation": "analyze", + "modelId": { + "__rl": true, + "value": "models/gemini-2.5-flash", + "mode": "list", + "cachedResultName": "models/gemini-2.5-flash" + }, + "text": "=Você é um Treinador Físico de Elite e Nutricionista Esportivo.\nAnalise a imagem fornecida (foto de corpo inteiro/físico).\n\n1. Verifique se é uma foto de corpo humano válida para análise fitness. Se não, retorne \"valid_body\": false.\n2. Se for válida, estime:\n - Biótipo Predominante (Ectomorfo, Mesomorfo, Endomorfo)\n - Percentual de Gordura (BF% aproximado)\n - Nível de Massa Muscular (Baixo, Médio, Alto)\n3. Sugira:\n - Objetivo Principal (Cutting, Bulking, Manutenção)\n - Divisão de Treino Recomendada (ex: ABC, ABCDE, Upper/Lower)\n - Foco Dietético (ex: Carb Cycling, Alto Carbo, Keto)\n - Dica de Ouro (uma frase curta de impacto)\n\nResponda APENAS em JSON estrito (sem markdown):\n{\n \"valid_body\": true,\n \"biotype\": \"...\",\n \"estimated_body_fat\": 15,\n \"muscle_mass\": \"Médio\",\n \"goal\": \"Bulking\",\n \"workout\": \"...\",\n \"diet\": \"...\",\n \"tip\": \"...\"\n}", + "inputType": "binary", + "simplify": false, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.googleGemini", + "typeVersion": 1, + "position": [ + 1080, + -700 + ], + "id": "gemini-coach", + "name": "Gemini Coach", + "credentials": { + "googlePalmApi": { + "id": "T2uIVBcjJ9h8BFCC", + "name": "Backup APIKEY" + } + } + }, + { + "parameters": { + "jsCode": "const raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\nconst clean = raw.replace(/```json/g, '').replace(/```/g, '').trim();\nlet data = {};\ntry { \n data = JSON.parse(clean); \n} catch(e) {\n data = { valid_body: false, error: 'Failed to parse AI' };\n}\n\nreturn [{ ...data }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + -700 + ], + "id": "parse-coach", + "name": "Parse Coach" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "insert into public.coach_analyses\n(\n user_id,\n source,\n image_url,\n ai_raw_response,\n ai_structured,\n biotype,\n estimated_body_fat,\n goal_suggestion,\n muscle_mass_level,\n used_free_quota\n)\nvalues\n(\n cast('{{ $(\"Validar Coach\").item.json.user_id }}' as uuid),\n 'whatsapp',\n null,\n '{{ JSON.stringify($json) }}',\n '{{ JSON.stringify($json) }}',\n '{{ $json.biotype }}',\n cast({{ $json.estimated_body_fat || 0 }} as numeric),\n '{{ $json.goal }}',\n '{{ $json.muscle_mass }}',\n CASE\n WHEN {{ $(\"Validar Coach\").item.json.plan_active }} = true THEN false\n ELSE true\n END\n)\nreturning id as analysis_id;", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + 1520, + -700 + ], + "id": "save-coach", + "name": "Salvar Coach DB", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('NormalizeInbound').item.json.number }}", + "messageText": "=⚡ *Coach AI*\n\n🧬 *Biótipo*: {{$json.biotype}}\n⚖️ *BF*: ~{{$json.estimated_body_fat}}%\n💪 *Massa*: {{$json.muscle_mass}}\n\n🎯 *Foco*: {{$json.goal}}\n🏋️ *Treino*: {{$json.workout}}\n\n💡 *Dica*: {{$json.tip}}\n\n_Veja mais no App!_", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 1740, + -700 + ], + "id": "reply-coach", + "name": "Responder Coach", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "70a7760f-4a83-4a80-bbbc-9eaf93a06a33", + "leftValue": "={{$json.exists}}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 160, + -1040 + ], + "id": "9e1ea558-2466-4426-9c9f-f5051e76da4f", + "name": "Usuário existe?" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "873dc279-9223-464a-b632-bf019f20c030", + "leftValue": "={{ $json.can_process }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 380, + -1120 + ], + "id": "63ea8c4f-ebe3-4c0b-95bb-51dc0c64d639", + "name": "Pode usar Food?" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "a5e5b4e5-ce26-4b2b-90e6-ba96e55006a8", + "leftValue": "={{$node[\"NormalizeInbound\"].json.hasImage}}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 600, + -1020 + ], + "id": "7130f3dd-fc60-4cba-b064-d7d98e846b86", + "name": "If texto? imagem?" + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('NormalizeInbound').item.json.number }}", + "messageText": "👋 Olá! Envie uma *foto do prato* para calorias ou escreva *'Coach'* e envie uma foto do corpo para análise física.", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 820, + -920 + ], + "id": "5f897b38-e120-4f68-8870-6d793d22a3ff", + "name": "Enviar texto help", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('NormalizeInbound').item.json.number }}", + "messageText": "📸 Recebi sua foto! Analisando o prato... ⏳", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 820, + -1120 + ], + "id": "704eec2f-2f98-4872-b414-c805f0642ef3", + "name": "Ack_Recebi_Foto", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "chat-api", + "operation": "get-media-base64", + "instanceName": "FoodSnap", + "messageId": "={{ $('Requisicao - Whatsapp').item.json.body.data.key.id }}" + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 1040, + -1120 + ], + "id": "200672ed-2daf-46ee-9853-08bff8b55c86", + "name": "Imagem Base64", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "operation": "toBinary", + "sourceProperty": "data.base64", + "options": {} + }, + "type": "n8n-nodes-base.convertToFile", + "typeVersion": 1.1, + "position": [ + 1260, + -1120 + ], + "id": "0076759b-6f96-4f58-a828-bd329a803054", + "name": "Converter Base64/Binario" + }, + { + "parameters": { + "resource": "image", + "operation": "analyze", + "modelId": { + "__rl": true, + "value": "models/gemini-2.5-flash", + "mode": "list", + "cachedResultName": "models/gemini-2.5-flash" + }, + "text": "=Você é um assistente nutricional... (Prompt Original de Comida)... Retorne SOMENTE JSON.", + "inputType": "binary", + "simplify": false, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.googleGemini", + "typeVersion": 1, + "position": [ + 1480, + -1120 + ], + "id": "629515c5-8021-4162-9a5f-aac2c5f4cb82", + "name": "Analyze an image Food", + "credentials": { + "googlePalmApi": { + "id": "T2uIVBcjJ9h8BFCC", + "name": "Backup APIKEY" + } + } + }, + { + "parameters": { + "jsCode": "// Código original de limpeza do Food\nconst raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\n// ... continuação do código original ...\nconst clean = raw.replace(/```json/gi, \"\").replace(/```/g, \"\").trim();\nlet parsed = JSON.parse(clean);\n// ... normalizações ...\nreturn [parsed];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1700, + -1120 + ], + "id": "6c7ed1f5-61d5-4911-b518-6bb3ff1205b5", + "name": "Limpar Resultado Food" + }, + { + "parameters": { + "jsCode": "const payload = Array.isArray($json) ? $json[0] : $json;\nconst sender = payload?.sender || $node[\"NormalizeInbound\"]?.json?.number || \"\";\nconst analysis_json = payload && typeof payload === \"object\" ? payload : {};\nconst updated_at = new Date().toISOString();\nreturn [{ sender, analysis_json, updated_at }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1920, + -1120 + ], + "id": "b6f194fa-71ba-4b05-b448-fdb67957ae1b", + "name": "Salvar Analise Prep" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "insert into public.food_analyses (user_id, source, ai_raw_response, ai_structured, total_calories) values (cast('{{ $(\"Validar Usuario (Food)\").item.json.user_id }}' as uuid), 'whatsapp', cast('{{ $node[\"Analyze an image Food\"].json.candidates[0].content.parts[0].text }}' as text), cast('{{ JSON.stringify($node[\"Limpar Resultado Food\"].json) }}' as jsonb), cast({{ $node[\"Limpar Resultado Food\"].json.total.calories }} as int)) returning id as analysis_id;", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + 2140, + -1120 + ], + "id": "556c631a-4a54-4cc1-b6f1-dd0473135a0f", + "name": "Salvar historico Food", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "jsCode": "const items = $json.analysis_json?.items || [];\nconst total = $json.analysis_json?.total || {};\nconst lines = [\"🥗 *RELATÓRIO PRATOFIT*\"];\n// ... lógica original de formatação ...\nreturn [{ text: lines.join(\"\\n\") }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2360, + -1120 + ], + "id": "e3bdb17a-fb9c-4178-b751-3c80ee616bd7", + "name": "Formatar Resposta Food" + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{$node[\"NormalizeInbound\"].json.number}}", + "messageText": "={{$json.text}}", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 2580, + -1120 + ], + "id": "e887d0a0-9bcb-4820-95dc-9c115a9b2a48", + "name": "Resposta WPP Food", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('NormalizeInbound').item.json.number }}", + "messageText": "🚫 *Acesso restrito* Seu número não está cadastrado. Cadastre-se em: https://foodsnap.com.br", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 400, + -880 + ], + "id": "c9352ebc-63bc-421a-9e79-a98492ec996a", + "name": "Nao Cadastrado" + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('NormalizeInbound').item.json.number }}", + "messageText": "🚫 Limite gratuito atingido. Assine um plano em foodsnap.com.br", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 600, + -1220 + ], + "id": "baea905c-add5-48a8-9c9e-81441c6c56d9", + "name": "Sem Plano Food", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + } + ], + "connections": { + "Requisicao - Whatsapp": { + "main": [ + [ + { + "node": "NormalizeInbound", + "type": "main", + "index": 0 + } + ] + ] + }, + "NormalizeInbound": { + "main": [ + [ + { + "node": "Roteador (Food/Coach)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Roteador (Food/Coach)": { + "main": [ + [ + { + "node": "Validar Usuario (Food)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Validar Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar Usuario (Food)": { + "main": [ + [ + { + "node": "Usuário existe?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Usuário existe?": { + "main": [ + [ + { + "node": "Pode usar Food?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Nao Cadastrado", + "type": "main", + "index": 0 + } + ] + ] + }, + "Pode usar Food?": { + "main": [ + [ + { + "node": "If texto? imagem?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Sem Plano Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "If texto? imagem?": { + "main": [ + [ + { + "node": "Ack_Recebi_Foto", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Enviar texto help", + "type": "main", + "index": 0 + } + ] + ] + }, + "Ack_Recebi_Foto": { + "main": [ + [ + { + "node": "Imagem Base64", + "type": "main", + "index": 0 + } + ] + ] + }, + "Imagem Base64": { + "main": [ + [ + { + "node": "Converter Base64/Binario", + "type": "main", + "index": 0 + } + ] + ] + }, + "Converter Base64/Binario": { + "main": [ + [ + { + "node": "Analyze an image Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Analyze an image Food": { + "main": [ + [ + { + "node": "Limpar Resultado Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Limpar Resultado Food": { + "main": [ + [ + { + "node": "Salvar Analise Prep", + "type": "main", + "index": 0 + } + ] + ] + }, + "Salvar Analise Prep": { + "main": [ + [ + { + "node": "Salvar historico Food", + "type": "main", + "index": 0 + }, + { + "node": "Formatar Resposta Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Formatar Resposta Food": { + "main": [ + [ + { + "node": "Resposta WPP Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validar Coach": { + "main": [ + [ + { + "node": "Pode usar Coach?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Pode usar Coach?": { + "main": [ + [ + { + "node": "Ack Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Ack Coach": { + "main": [ + [ + { + "node": "Baixar IMG Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baixar IMG Coach": { + "main": [ + [ + { + "node": "Binary Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Binary Coach": { + "main": [ + [ + { + "node": "Gemini Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gemini Coach": { + "main": [ + [ + { + "node": "Parse Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Coach": { + "main": [ + [ + { + "node": "Salvar Coach DB", + "type": "main", + "index": 0 + } + ] + ] + }, + "Salvar Coach DB": { + "main": [ + [ + { + "node": "Responder Coach", + "type": "main", + "index": 0 + } + ] + ] + } + } +} \ No newline at end of file diff --git a/src/n8n-foodsnap-unified.json b/src/n8n-foodsnap-unified.json new file mode 100644 index 0000000..4710b91 --- /dev/null +++ b/src/n8n-foodsnap-unified.json @@ -0,0 +1,713 @@ +{ + "name": "FoodSnap - Unified (Food & Coach)", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "wa/inbound", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -680, + -940 + ], + "id": "webhook-unified", + "name": "Webhook (Whatsapp)" + }, + { + "parameters": { + "jsCode": "const body = $json.body ?? $json;\nconst data = body.data ?? {};\n\n// =========================\n// RemoteJid (prioridade s.whatsapp.net)\n// =========================\nconst remoteJid =\n data?.key?.remoteJid?.includes('@s.whatsapp.net')\n ? data.key.remoteJid\n : data?.key?.remoteJidAlt || '';\n\n// número limpo (E.164 sem +)\nconst number = remoteJid.replace(/\\D/g, '');\n\n// =========================\n// Message ID\n// =========================\nconst message_id = data?.key?.id || '';\n\n// =========================\n// Texto e Caption\n// =========================\n// Verifica conversation, extendedTextMessage (text) e imageMessage (caption)\nconst text =\n data?.message?.conversation ||\n data?.message?.extendedTextMessage?.text ||\n data?.message?.imageMessage?.caption ||\n '';\n\n// =========================\n// Imagem\n// =========================\nconst imageMessage =\n data?.message?.imageMessage ||\n data?.message?.extendedTextMessage?.contextInfo?.quotedMessage?.imageMessage ||\n null;\n\n// =========================\n// Return normalizado\n// =========================\nreturn [\n {\n number,\n remoteJid,\n message_id,\n text,\n hasImage: !!imageMessage,\n imageMessage,\n pushName: data?.pushName || 'Atleta',\n timestamp: new Date().toISOString(),\n raw: body\n }\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -460, + -940 + ], + "id": "normalize-inbound", + "name": "Normalizar Dados" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": false, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "check-coach", + "leftValue": "={{ $json.text }}", + "rightValue": "coach,treino,shape,biotipo,fisico,musculo", + "operator": { + "type": "string", + "operation": "contains", + "singleValue": true + } + } + ], + "combinator": "or" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -240, + -940 + ], + "id": "router-intent", + "name": "É Coach?" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "with u as (\n select id\n from public.profiles\n where phone_e164 = cast({{ $('Normalizar Dados').item.json.number }} as text)\n limit 1\n),\nent as (\n select ue.is_active, ue.entitlement_code, ue.valid_until\n from public.user_entitlements ue\n where ue.user_id = (select id from u)\n order by ue.valid_until desc nulls last\n limit 1\n),\nusage as (\n select count(*) as used_count\n from public.coach_analyses fa\n where fa.user_id = (select id from u)\n and fa.used_free_quota = true\n)\nselect\n (select id from u) as user_id,\n (select id from u) is not null as exists,\n coalesce((select used_count from usage), 0)::int as free_used,\n greatest(0, 3 - coalesce((select used_count from usage), 0))::int as free_remaining,\n coalesce((select is_active from ent), false) as plan_active,\n (coalesce((select is_active from ent), false) = true OR greatest(0, 3 - coalesce((select used_count from usage), 0)) > 0) as can_process", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + 20, + -1160 + ], + "id": "validate-coach", + "name": "Validação Coach", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "with u as (\n select id\n from public.profiles\n where phone_e164 = cast({{ $('Normalizar Dados').item.json.number }} as text)\n limit 1\n),\nent as (\n select\n ue.user_id,\n ue.is_active,\n ue.entitlement_code\n from public.user_entitlements ue\n where ue.user_id = (select id from u)\n order by ue.valid_until desc nulls last\n limit 1\n),\nusage as (\n select count(*) filter (where fa.used_free_quota = true) as free_used\n from public.food_analyses fa\n where fa.user_id = (select id from u)\n)\nselect\n (select id from u) is not null as exists,\n (select id from u) as user_id,\n coalesce((select free_used from usage), 0)::int as free_used,\n greatest(0, 5 - coalesce((select free_used from usage), 0))::int as free_remaining,\n coalesce((select is_active from ent), false) as plan_active,\n (\n (select id from u) is not null\n and (\n coalesce((select is_active from ent), false) = true\n or greatest(0, 5 - coalesce((select free_used from usage), 0)) > 0\n )\n ) as can_process;", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + 20, + -740 + ], + "id": "validate-food", + "name": "Validação Food", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "check-process", + "leftValue": "={{ $json.can_process }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 260, + -1160 + ], + "id": "if-coach-quota", + "name": "Coach OK?" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "check-process-food", + "leftValue": "={{ $json.can_process }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 260, + -740 + ], + "id": "if-food-quota", + "name": "Food OK?" + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "🧐 *Coach AI*: Analisando seu biótipo e gerando seu treino... Aguarde!", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 500, + -1260 + ], + "id": "msg-ack-coach", + "name": "Ack Coach", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "chat-api", + "operation": "get-media-base64", + "instanceName": "FoodSnap", + "messageId": "={{ $('Normalizar Dados').item.json.message_id }}" + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 720, + -1260 + ], + "id": "get-image-coach", + "name": "Baixar IMG Coach", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "operation": "toBinary", + "sourceProperty": "data.base64", + "options": {} + }, + "type": "n8n-nodes-base.convertToFile", + "typeVersion": 1.1, + "position": [ + 940, + -1260 + ], + "id": "convert-binary-coach", + "name": "Binário Coach" + }, + { + "parameters": { + "resource": "image", + "operation": "analyze", + "modelId": { + "__rl": true, + "value": "models/gemini-1.5-flash", + "mode": "list", + "cachedResultName": "models/gemini-1.5-flash" + }, + "text": "=Você é um Treinador Físico de Elite e Nutricionista Esportivo.\nAnalise a imagem fornecida (foto de corpo inteiro/físico).\n\n1. Verifique se é uma foto de corpo humano válida para análise fitness. Se não, retorne \"valid_body\": false.\n2. Se for válida, estime:\n - Biótipo Predominante (Ectomorfo, Mesomorfo, Endomorfo)\n - Percentual de Gordura (BF% aproximado)\n - Nível de Massa Muscular (Baixo, Médio, Alto)\n3. Sugira:\n - Objetivo Principal (Cutting, Bulking, Manutenção)\n - Divisão de Treino Recomendada (ex: ABC, ABCDE, Upper/Lower)\n - Foco Dietético (ex: Carb Cycling, Alto Carbo, Keto)\n - Dica de Ouro (uma frase curta de impacto)\n\nResponda APENAS em JSON estrito (sem markdown):\n{\n \"valid_body\": true,\n \"biotype\": \"...\",\n \"estimated_body_fat\": 15,\n \"muscle_mass\": \"Médio\",\n \"goal\": \"Bulking\",\n \"workout\": \"...\",\n \"diet\": \"...\",\n \"tip\": \"...\"\n}", + "inputType": "binary", + "simplify": false, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.googleGemini", + "typeVersion": 1, + "position": [ + 1160, + -1260 + ], + "id": "gemini-coach", + "name": "Gemini Coach", + "credentials": { + "googlePalmApi": { + "id": "T2uIVBcjJ9h8BFCC", + "name": "Backup APIKEY" + } + } + }, + { + "parameters": { + "jsCode": "const raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\nconst clean = raw.replace(/```json/g, '').replace(/```/g, '').trim();\nlet data = {};\ntry { \n data = JSON.parse(clean); \n} catch(e) {\n data = { valid_body: false, error: 'Failed to parse AI' };\n}\n\nreturn [{ ...data }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1380, + -1260 + ], + "id": "parse-coach-json", + "name": "Parse Coach JSON" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "insert into public.coach_analyses\n(\n user_id,\n source,\n image_url,\n ai_raw_response,\n ai_structured,\n biotype,\n estimated_body_fat,\n goal_suggestion,\n muscle_mass_level,\n used_free_quota\n)\nvalues\n(\n cast('{{ $(\"Validação Coach\").item.json.user_id }}' as uuid),\n 'whatsapp',\n null,\n '{{ JSON.stringify($json) }}',\n '{{ JSON.stringify($json) }}',\n '{{ $json.biotype }}',\n cast({{ $json.estimated_body_fat || 0 }} as numeric),\n '{{ $json.goal }}',\n '{{ $json.muscle_mass }}',\n CASE\n WHEN {{ $(\"Validação Coach\").item.json.plan_active }} = true THEN false\n ELSE true\n END\n)\nreturning id as analysis_id;", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + 1600, + -1260 + ], + "id": "save-coach-db", + "name": "Salvar Coach DB", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "=⚡ *Coach AI Report*\n\n🧬 *Biótipo*: {{$json.biotype}}\n⚖️ *BF Estimado*: ~{{$json.estimated_body_fat}}%\n💪 *Massa Muscular*: {{$json.muscle_mass}}\n\n🎯 *Foco*: {{$json.goal}}\n🏋️ *Treino*: {{$json.workout}}\n🥗 *Dieta*: {{$json.diet}}\n\n💡 *Dica*: {{$json.tip}}\n\n_Acesse o App para ver a ficha completa!_", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 1820, + -1260 + ], + "id": "reply-coach", + "name": "Responder Coach", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "📸 Recebi sua foto! Analisando o prato... ⏳", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 500, + -740 + ], + "id": "ack-food", + "name": "Ack Food", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "chat-api", + "operation": "get-media-base64", + "instanceName": "FoodSnap", + "messageId": "={{ $('Normalizar Dados').item.json.message_id }}" + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 720, + -740 + ], + "id": "get-image-food", + "name": "Baixar IMG Food", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "operation": "toBinary", + "sourceProperty": "data.base64", + "options": {} + }, + "type": "n8n-nodes-base.convertToFile", + "typeVersion": 1.1, + "position": [ + 940, + -740 + ], + "id": "convert-binary-food", + "name": "Binário Food" + }, + { + "parameters": { + "resource": "image", + "operation": "analyze", + "modelId": { + "__rl": true, + "value": "models/gemini-1.5-flash", + "mode": "list", + "cachedResultName": "models/gemini-1.5-flash" + }, + "text": "=Você é um assistente nutricional... (Prompt Original de Comida)... Retorne SOMENTE JSON.", + "inputType": "binary", + "simplify": false, + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.googleGemini", + "typeVersion": 1, + "position": [ + 1160, + -740 + ], + "id": "gemini-food", + "name": "Gemini Food", + "credentials": { + "googlePalmApi": { + "id": "T2uIVBcjJ9h8BFCC", + "name": "Backup APIKEY" + } + } + }, + { + "parameters": { + "jsCode": "// Limpeza de JSON da Comida (Original)\nconst raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\n// ... lógica existente de parse do FoodSnap ...\nreturn [{\n items: [],\n total: { calories: 500, protein: 30 },\n tip: { text: \"Exemplo de análise de comida\" }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1380, + -740 + ], + "id": "parse-food", + "name": "Parse Food", + "notes": "Lógica completa de parse de comida aqui (resumida para o arquivo)" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "insert into public.food_analyses ... (SQL Original)", + "options": {} + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.6, + "position": [ + 1600, + -740 + ], + "id": "save-food-db", + "name": "Salvar Food DB", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "=🥗 *FoodSnap*: Calorias: {{$json.total.calories}} ... (Formato Original)", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 1820, + -740 + ], + "id": "reply-food", + "name": "Responder Food", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + }, + { + "parameters": { + "resource": "messages-api", + "instanceName": "FoodSnap", + "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", + "messageText": "⚠️ Por favor, envie uma *imagem* para análise.", + "options_message": {} + }, + "type": "n8n-nodes-evolution-api.evolutionApi", + "typeVersion": 1, + "position": [ + 260, + -500 + ], + "id": "msg-no-image", + "name": "Sem Imagem", + "credentials": { + "evolutionApi": { + "id": "nGWBERcZoQWgdxgk", + "name": "FoodSnap" + } + } + } + ], + "connections": { + "Webhook (Whatsapp)": { + "main": [ + [ + { + "node": "Normalizar Dados", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalizar Dados": { + "main": [ + [ + { + "node": "É Coach?", + "type": "main", + "index": 0 + } + ] + ] + }, + "É Coach?": { + "main": [ + [ + { + "node": "Validação Coach", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Validação Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validação Coach": { + "main": [ + [ + { + "node": "Coach OK?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validação Food": { + "main": [ + [ + { + "node": "Food OK?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Coach OK?": { + "main": [ + [ + { + "node": "Ack Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Food OK?": { + "main": [ + [ + { + "node": "Ack Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Ack Coach": { + "main": [ + [ + { + "node": "Baixar IMG Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Ack Food": { + "main": [ + [ + { + "node": "Baixar IMG Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baixar IMG Coach": { + "main": [ + [ + { + "node": "Binário Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Baixar IMG Food": { + "main": [ + [ + { + "node": "Binário Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Binário Coach": { + "main": [ + [ + { + "node": "Gemini Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Binário Food": { + "main": [ + [ + { + "node": "Gemini Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gemini Coach": { + "main": [ + [ + { + "node": "Parse Coach JSON", + "type": "main", + "index": 0 + } + ] + ] + }, + "Gemini Food": { + "main": [ + [ + { + "node": "Parse Food", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Coach JSON": { + "main": [ + [ + { + "node": "Salvar Coach DB", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Food": { + "main": [ + [ + { + "node": "Salvar Food DB", + "type": "main", + "index": 0 + } + ] + ] + }, + "Salvar Coach DB": { + "main": [ + [ + { + "node": "Responder Coach", + "type": "main", + "index": 0 + } + ] + ] + }, + "Salvar Food DB": { + "main": [ + [ + { + "node": "Responder Food", + "type": "main", + "index": 0 + } + ] + ] + } + } +} \ No newline at end of file diff --git a/src/n8n-stripe-webhook.json b/src/n8n-stripe-webhook.json new file mode 100644 index 0000000..3a64154 --- /dev/null +++ b/src/n8n-stripe-webhook.json @@ -0,0 +1,134 @@ +{ + "name": "Stripe Payment Handler", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "stripe-webhook", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 1, + "position": [ + 0, + 0 + ], + "id": "webhook-stripe", + "name": "Webhook Stripe" + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json.body.type }}", + "value2": "checkout.session.completed" + } + ] + } + }, + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 200, + 0 + ], + "id": "check-event-type", + "name": "Is Checkout Completed?" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT id FROM profiles WHERE email = '{{ $json.body.data.object.customer_details.email }}' LIMIT 1" + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 1, + "position": [ + 450, + -100 + ], + "id": "lookup-user", + "name": "Find User by Email", + "credentials": { + "postgres": { + "id": "2JDD2OJz4cAsWb0J", + "name": "foodsnap_supabase" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO payments (user_id, amount_cents, currency, status, stripe_payment_id, plan_type) VALUES ('{{ $json.id }}', {{ $node[\"Webhook Stripe\"].json.body.data.object.amount_total }}, '{{ $node[\"Webhook Stripe\"].json.body.data.object.currency }}', 'succeeded', '{{ $node[\"Webhook Stripe\"].json.body.data.object.payment_intent }}', 'pro') RETURNING id" + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 1, + "position": [ + 650, + -100 + ], + "id": "log-payment", + "name": "Log Payment" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO user_entitlements (user_id, entitlement_code, is_active, is_trial, valid_until, plan_type) VALUES ('{{ $node[\"Find User by Email\"].json.id }}', 'pro', true, false, NOW() + INTERVAL '30 days', 'pro') ON CONFLICT (user_id) DO UPDATE SET is_active = true, valid_until = NOW() + INTERVAL '30 days', entitlement_code = 'pro', updated_at = NOW();" + }, + "type": "n8n-nodes-base.postgres", + "typeVersion": 1, + "position": [ + 850, + -100 + ], + "id": "activate-plan", + "name": "Activate Plan" + } + ], + "connections": { + "Webhook Stripe": { + "main": [ + [ + { + "node": "Is Checkout Completed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is Checkout Completed?": { + "main": [ + [ + { + "node": "Find User by Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Find User by Email": { + "main": [ + [ + { + "node": "Log Payment", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Payment": { + "main": [ + [ + { + "node": "Activate Plan", + "type": "main", + "index": 0 + } + ] + ] + } + } +} \ No newline at end of file diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx new file mode 100644 index 0000000..116d906 --- /dev/null +++ b/src/pages/AdminPanel.tsx @@ -0,0 +1,798 @@ +import React, { useEffect, useState } from 'react'; +import { + LayoutDashboard, + Users, + CreditCard, + LogOut, + ArrowLeft, + TrendingUp, + Ticket, + Search, + ShieldAlert, + Download, + Plus, + DollarSign, + Calendar, + CheckCircle2, + XCircle, + MoreHorizontal, + UserPlus, + Activity, + AlertTriangle, + User, + Clock, + Info, + Settings, + Save, + Smartphone +} from 'lucide-react'; +import { supabase } from '@/lib/supabase'; +import { User as AppUser } from '@/types'; + +interface AdminPanelProps { + user: AppUser; + onExitAdmin: () => void; + onLogout: () => void; +} + +// Tipos baseados nas novas tabelas SQL +type TabType = 'overview' | 'users' | 'financial' | 'coupons' | 'settings'; + +const AdminPanel: React.FC = ({ user, onExitAdmin, onLogout }) => { + const [activeTab, setActiveTab] = useState('overview'); + const [loading, setLoading] = useState(true); + + // Data States + const [stats, setStats] = useState(null); + const [usersList, setUsersList] = useState([]); + const [coupons, setCoupons] = useState([]); + + // Settings State + const [config, setConfig] = useState({ + whatsapp_number: '' // Inicializa vazio para não confundir + }); + const [savingConfig, setSavingConfig] = useState(false); + + // UI States + const [searchTerm, setSearchTerm] = useState(''); + const [showCouponModal, setShowCouponModal] = useState(false); + const [newCoupon, setNewCoupon] = useState({ code: '', percent: 10, uses: 100 }); + + useEffect(() => { + fetchDashboardData(); + }, []); + + const fetchDashboardData = async () => { + setLoading(true); + try { + // 1. Stats + const { data: sData } = await supabase.rpc('get_admin_dashboard_stats'); + if (sData) setStats(sData); + + // 2. Users (Robust Fetch) + await fetchUsersSafe(); + + // 3. Coupons + const { data: cData } = await supabase.from('coupons').select('*').order('created_at', { ascending: false }); + if (cData) setCoupons(cData); + + // 4. Settings + const { data: configData } = await supabase + .from('app_settings') + .select('value') + .eq('key', 'whatsapp_number') + .maybeSingle(); + + if (configData) { + setConfig({ whatsapp_number: configData.value }); + } else { + // Fallback visual apenas se não tiver nada no banco + setConfig({ whatsapp_number: '5541999999999' }); + } + + } catch (error) { + console.error("Admin fetch error", error); + } finally { + setLoading(false); + } + }; + + const fetchUsersSafe = async () => { + // Tenta usar a função avançada (RPC) que tem dados do plano + const { data: rpcData, error: rpcError } = await supabase.rpc('get_admin_users_list', { limit_count: 50 }); + + if (!rpcError && rpcData) { + setUsersList(rpcData); + return; + } + + // Se falhar (ex: SQL não atualizado), busca o básico da tabela profiles para não deixar a tela vazia + console.warn("RPC falhou, usando fallback de perfis:", rpcError); + const { data: basicData } = await supabase + .from('profiles') + .select('*') + .order('created_at', { ascending: false }) + .limit(50); + + if (basicData) { + const mapped = basicData.map(p => ({ + id: p.id, + full_name: p.full_name, + email: p.email, + phone: p.phone_e164, + created_at: p.created_at, + plan_status: 'free', + plan_interval: 'free', + lifetime_value: 0, + plan_start_date: null, + plan_end_date: null + })); + setUsersList(mapped); + } + }; + + const handleCreateCoupon = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const { error } = await supabase.rpc('admin_create_coupon', { + p_code: newCoupon.code, + p_percent: newCoupon.percent, + p_uses: newCoupon.uses + }); + + if (error) throw error; + + const { data: cData } = await supabase.from('coupons').select('*').order('created_at', { ascending: false }); + if (cData) setCoupons(cData); + + setShowCouponModal(false); + setNewCoupon({ code: '', percent: 10, uses: 100 }); + alert("Cupom criado com sucesso!"); + } catch (err: any) { + alert("Erro ao criar cupom: " + err.message); + } + }; + + const handleToggleProfessional = async (userId: string, newValue: boolean) => { + try { + const { error } = await supabase + .from('profiles') + .update({ is_professional: newValue }) + .eq('id', userId); + + if (error) throw error; + + // Optimistic update + setUsersList(prev => prev.map(u => + u.id === userId ? { ...u, is_professional: newValue } : u + )); + + // If enhancing to Professional, check/create the professionals record + if (newValue) { + const { data: existing } = await supabase.from('professionals').select('id').eq('id', userId).maybeSingle(); + if (!existing) { + // Auto-init profile + // We don't have the user name here easily unless we look it up, + // but we can trust the 'professionals' RLS or just let them create it on first login. + // Ideally, we create a stub here. + const user = usersList.find(u => u.id === userId); + if (user) { + await supabase.from('professionals').insert({ + id: userId, + business_name: user.full_name || 'Novo Profissional', + primary_color: '#059669' // Default Green + }); + } + } + } + + // toast.success(`Status alterado para ${newValue ? 'Profissional' : 'Aluno'}`); + + } catch (error) { + console.error("Error toggling pro status:", error); + alert("Erro ao alterar status!"); + } + }; + + const handleSaveSettings = async (e: React.FormEvent) => { + e.preventDefault(); + setSavingConfig(true); + try { + const { error } = await supabase + .from('app_settings') + .upsert({ key: 'whatsapp_number', value: config.whatsapp_number }, { onConflict: 'key' }); + + if (error) throw error; + alert("Configurações salvas com sucesso!"); + } catch (err: any) { + console.error(err); + alert("Erro ao salvar: " + err.message); + } finally { + setSavingConfig(false); + } + }; + + // Safe filter logic (handles null names) + const filteredUsers = usersList.filter(u => + (u.full_name || '').toLowerCase().includes(searchTerm.toLowerCase()) || + (u.email || '').toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const formatCurrency = (cents: number) => { + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(cents / 100); + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleDateString('pt-BR'); + }; + + return ( +
+ + {/* Sidebar Premium */} + + + {/* Main Content */} +
+ {loading ? ( +
+
+
+ ) : ( +
+ + {/* Top Bar */} +
+
+

+ {activeTab === 'overview' && 'Visão Geral'} + {activeTab === 'users' && 'Gestão de Usuários'} + {activeTab === 'financial' && 'Controle Financeiro'} + {activeTab === 'coupons' && 'Cupons de Desconto'} + {activeTab === 'settings' && 'Configurações do Sistema'} +

+

+ + Sistema Operacional • {new Date().toLocaleDateString()} +

+
+
+ {activeTab !== 'settings' && ( + + )} + {activeTab === 'coupons' && ( + + )} +
+
+ + {/* --- OVERVIEW TAB --- */} + {activeTab === 'overview' && stats && ( +
+ {/* Stats Grid */} +
+ } + color="bg-emerald-500" + trend="+8.2%" + /> + } + color="bg-blue-500" + trend="+12" + /> + } + color="bg-indigo-500" + trend="+24" + /> + } + color="bg-purple-500" + trend="Hoje" + /> +
+ + {/* Recent Activity Section */} +
+ {/* Chart placeholder area */} +
+

Crescimento de Receita (Simulado)

+
+ {[40, 65, 50, 80, 75, 90, 85, 100].map((h, i) => ( +
+
+
+ ))} +
+
+ JanFevMarAbrMaiJunJulAgo +
+
+ + {/* Quick Actions / Recent */} +
+

Ações Rápidas

+
+ + +
+
+
+
+ )} + + {/* --- USERS TAB --- */} + {activeTab === 'users' && ( +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ +
+
+ + + + + + + + + + + + + + {filteredUsers.length === 0 ? ( + + + + ) : filteredUsers.map((u) => ( + + + + + + + + + + ))} + +
UsuárioStatusPlanoInícioTérminoPro?LTV
+ Nenhum usuário encontrado na busca. +
+
+
+ {u.full_name ? u.full_name.substring(0, 2).toUpperCase() : 'US'} +
+
+
{u.full_name || 'Usuário Sem Nome'}
+
{u.email}
+
+
+
+ + + + + {u.plan_start_date ? ( + {formatDate(u.plan_start_date)} + ) : ( +
+ + {formatDate(u.created_at)} +
+ )} +
+ {u.plan_end_date ? formatDate(u.plan_end_date) : -} + + + + {formatCurrency(u.lifetime_value || 0)} +
+
+
+
+ )} + + {/* --- COUPONS TAB --- */} + {activeTab === 'coupons' && ( +
+
+
+

Marketing & Ofertas

+

+ Crie códigos promocionais para influenciadores, campanhas de email ou recuperação de carrinho. +

+
+ +
+ +
+ {coupons.length === 0 ? ( +
+ Nenhum cupom ativo no momento. +
+ ) : coupons.map(c => ( +
+
+ +
+
+
+
+ {c.code} +
+
+ {c.is_active ? 'ATIVO' : 'INATIVO'} +
+
+
+ {c.discount_percent}% + OFF +
+
+
+ + {c.uses_count} / {c.max_uses} usos +
+
+ + Criado em {new Date(c.created_at).toLocaleDateString()} +
+
+
+
+ ))} +
+
+ )} + + {/* --- SETTINGS TAB --- */} + {activeTab === 'settings' && ( +
+
+
+

+ + Integração WhatsApp +

+

+ Configure o número que receberá as mensagens e imagens dos usuários para análise. +

+
+
+
+
+ +
+ setConfig({ ...config, whatsapp_number: e.target.value.replace(/\D/g, '') })} + /> + +
+

+ Insira apenas números, incluindo o código do país (Ex: 55 para Brasil). Este número será usado para gerar o QR Code no painel do usuário. +

+
+ +
+ +
+
+
+
+
+ )} + + {/* --- FINANCIAL TAB --- */} + {activeTab === 'financial' && ( +
+
+
+ +
+

Integração Stripe Connect

+

+ Para visualizar o histórico detalhado de transações em tempo real, configure os Webhooks do Stripe no backend. O sistema atual está pronto para receber os dados na tabela payments. +

+ +
+
+ )} +
+ )} +
+ + {/* Coupon Modal */} + {showCouponModal && ( +
+
+

Criar Novo Cupom

+ +
+ +

+ Atenção: Ao criar o cupom aqui, você apenas registra para métricas internas.
+ Você deve criar o mesmo código de cupom no Dashboard do Stripe para que o desconto funcione no checkout. +

+
+ +
+
+ + setNewCoupon({ ...newCoupon, code: e.target.value })} + /> +
+
+
+ + setNewCoupon({ ...newCoupon, percent: parseInt(e.target.value) })} + /> +
+
+ + setNewCoupon({ ...newCoupon, uses: parseInt(e.target.value) })} + /> +
+
+
+ + +
+
+
+
+ )} +
+ ); +}; + +// UI Components +const NavButton = ({ active, onClick, icon, label }: any) => ( + +); + +const KpiCard = ({ title, value, icon, color, trend }: any) => ( +
+
+ {icon} +
+
+

{title}

+

{value}

+ {trend && ( +
+ {trend} +
+ )} +
+
+); + +const StatusBadge = ({ status }: { status: string }) => { + let styles = 'bg-gray-100 text-gray-600'; + let icon = ; + let label = status; + + if (status === 'pro') { + styles = 'bg-green-100 text-green-700 border border-green-200'; + icon = ; + } + else if (status === 'trial') { + styles = 'bg-orange-100 text-orange-700 border border-orange-200'; + icon = ; + } + else if (status === 'free' || !status) { + return ( + + Gratuito + + ); + } + + return ( + + {icon} {label} + + ); +}; + +const IntervalBadge = ({ interval }: { interval: string }) => { + if (interval === 'free' || !interval) { + return Básico; + } + + let color = 'bg-gray-100 text-gray-600'; + let label = interval; + + if (interval === 'monthly') { color = 'bg-blue-50 text-blue-700 border border-blue-100'; label = 'Mensal'; } + if (interval === 'quarterly') { color = 'bg-indigo-50 text-indigo-700 border border-indigo-100'; label = 'Trimestral'; } + if (interval === 'annual') { color = 'bg-purple-50 text-purple-700 border border-purple-100'; label = 'Anual'; } + + return ( + + {label} + + ); +}; + +const ExternalLinkIcon = () => ( + +); + +export default AdminPanel; \ No newline at end of file diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..f82c54c --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,234 @@ +import React, { useState, useEffect } from 'react'; +import { + LayoutDashboard, History, CreditCard, Settings, LogOut, Plus, Search, Calendar, ChevronRight, Zap, ExternalLink, MessageCircle, Loader2, Utensils, ShieldAlert, Smartphone, QrCode, CheckCircle2, Dumbbell, Timer, PlayCircle, ScanEye, BrainCircuit, Activity, ScanLine, Sparkles, TrendingUp +} from 'lucide-react'; +import CoachWizard from '@/components/coach/CoachWizard'; +import { User } from '@/types'; +import { supabase } from '@/lib/supabase'; +import { useLanguage } from '@/contexts/LanguageContext'; + +// Custom Hooks +import { useDashboardStats } from '@/hooks/useDashboardStats'; +import { useDashboardHistory } from '@/hooks/useDashboardHistory'; +import { useCoachPlan } from '@/hooks/useCoachPlan'; + +// Layout Components +import Sidebar from '@/components/layout/Sidebar'; +import MobileNav from '@/components/layout/MobileNav'; + +// Feature Components +import DashboardOverview from '@/components/dashboard/DashboardOverview'; +import DashboardHistory from '@/components/dashboard/DashboardHistory'; +import DashboardSubscription from '@/components/dashboard/DashboardSubscription'; +import DashboardCoach from '@/components/dashboard/DashboardCoach'; + +interface DashboardProps { + user: User; + onLogout: () => void; + onOpenAdmin?: () => void; // Optional prop for admin toggle + onOpenPro?: () => void; // Optional prop for professional toggle +} + +const Dashboard: React.FC = ({ user, onLogout, onOpenAdmin, onOpenPro }) => { + const { t, language } = useLanguage(); + const [activeTab, setActiveTab] = useState<'overview' | 'history' | 'subscription' | 'coach'>('overview'); + const [isCoachWizardOpen, setIsCoachWizardOpen] = useState(false); + // Custom Hooks + const { stats, loadingStats } = useDashboardStats(user.id); + const { history, loadingHistory } = useDashboardHistory(user.id); + const { coachPlan, setCoachPlan, coachHistory } = useCoachPlan(user.id); + + // WhatsApp Config + const [whatsappNumber, setWhatsappNumber] = useState("5541999999999"); // Default fallback + + const fetchSystemSettings = async () => { + try { + const { data } = await supabase + .from('app_settings') + .select('value') + .eq('key', 'whatsapp_number') + .maybeSingle(); + + if (data && data.value) { + setWhatsappNumber(data.value); + } + } catch (err) { + console.error("Failed to fetch settings", err); + } + }; + + useEffect(() => { + fetchSystemSettings(); + + // Realtime Subscription: Escuta alterações na tabela app_settings + const settingsChannel = supabase + .channel('public:app_settings') + .on( + 'postgres_changes', + { + event: '*', // Escuta INSERT e UPDATE + schema: 'public', + table: 'app_settings', + filter: 'key=eq.whatsapp_number', + }, + (payload) => { + if (payload.new && (payload.new as any).value) { + setWhatsappNumber((payload.new as any).value); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(settingsChannel); + }; + }, [user.id]); + + const whatsappUrl = `https://wa.me/${whatsappNumber}?text=Oi`; + const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(whatsappUrl)}`; + + const handleStripePortal = async () => { + try { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + alert("Sessão expirada. Faça login novamente."); + return; + } + + const response = await fetch(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/stripe-checkout`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${session.access_token}`, + }, + }); + + if (!response.ok) throw new Error("Erro ao gerar link do portal"); + + const { url } = await response.json(); + if (url) { + window.location.href = url; + } else { + alert("Erro: URL do portal não retornada."); + } + } catch (error) { + console.error("Erro no portal:", error); + alert("Não foi possível acessar o portal de pagamentos."); + } + }; + + // Helper para o nome do plano (Correção do bug de nome vazio) + const getPlanLabel = () => { + if (user.plan === 'pro') return 'PRO'; + if (user.plan === 'trial') return 'Trial'; + // Traduções manuais para o plano gratuito + if (language === 'pt') return 'Gratuito'; + if (language === 'es') return 'Gratis'; + return 'Free'; + }; + + const planName = getPlanLabel(); + const fallbackImage = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=400&q=80"; + + return ( +
+ + {/* Sidebar Navigation */} + { + setCoachPlan(plan); + setActiveTab('coach'); + }} + /> + + {/* Mobile Bottom Navigation */} + + +
+ + {/* Mobile Header */} +
+ FoodSnap +
+ {onOpenAdmin && ( + + )} + +
+
+ + {/* Content Switcher */} + {activeTab === 'overview' && ( + + )} + + {activeTab === 'history' && ( + + )} + + {activeTab === 'subscription' && ( + + )} + + {activeTab === 'coach' && ( + + )} + +
+ + setIsCoachWizardOpen(false)} + onComplete={(data: any) => { + console.log("Wizard Completed:", data); + setCoachPlan(data); + setIsCoachWizardOpen(false); + }} + /> +
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/src/pages/FAQPage.tsx b/src/pages/FAQPage.tsx new file mode 100644 index 0000000..caf0c3e --- /dev/null +++ b/src/pages/FAQPage.tsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { Search, ChevronDown, ChevronUp, ArrowLeft, HelpCircle, FileText, CreditCard, Wrench } from 'lucide-react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +interface FAQPageProps { + onBack: () => void; +} + +const FAQPage: React.FC = ({ onBack }) => { + const { t } = useLanguage(); + const [search, setSearch] = useState(''); + const [openItem, setOpenItem] = useState(null); + + const categories = [ + { id: 'general', title: t.faqPage.categories.general.title, icon: , items: t.faqPage.categories.general.items }, + { id: 'account', title: t.faqPage.categories.account.title, icon: , items: t.faqPage.categories.account.items }, + { id: 'billing', title: t.faqPage.categories.billing.title, icon: , items: t.faqPage.categories.billing.items }, + { id: 'technical', title: t.faqPage.categories.technical.title, icon: , items: t.faqPage.categories.technical.items }, + ]; + + // Filtra as perguntas baseado na busca + const filteredCategories = categories.map(cat => ({ + ...cat, + items: cat.items.filter(item => + item.q.toLowerCase().includes(search.toLowerCase()) || + item.a.toLowerCase().includes(search.toLowerCase()) + ) + })).filter(cat => cat.items.length > 0); + + const toggleItem = (id: string) => { + setOpenItem(openItem === id ? null : id); + }; + + return ( +
+
+ + {/* Header Section */} +
+ +

{t.faqPage.title}

+

{t.faqPage.subtitle}

+ +
+
+ +
+ setSearch(e.target.value)} + /> +
+
+ + {/* Categories & Questions */} +
+ {filteredCategories.length === 0 ? ( +
+ Nenhuma pergunta encontrada para sua busca. +
+ ) : ( + filteredCategories.map((cat) => ( +
+
+
{cat.icon}
+

{cat.title}

+
+
+ {cat.items.map((item, idx) => { + const itemId = `${cat.id}-${idx}`; + const isOpen = openItem === itemId; + return ( +
+ +
+

+ {item.a} +

+
+
+ ); + })} +
+
+ )) + )} +
+ + {/* Contact CTA */} + + +
+
+ ); +}; + +export default FAQPage; \ No newline at end of file diff --git a/src/pages/ProfessionalDashboard.tsx b/src/pages/ProfessionalDashboard.tsx new file mode 100644 index 0000000..cc490bd --- /dev/null +++ b/src/pages/ProfessionalDashboard.tsx @@ -0,0 +1,176 @@ +import React, { useState, useEffect } from 'react'; +import { supabase } from '@/lib/supabase'; +import { + Users, + Dumbbell, + FileText, + Settings, + LogOut, + LayoutDashboard, + Video, + CreditCard, + Menu, + X, + Search, + Bell, + PlusCircle +} from 'lucide-react'; +import { User } from '@/types'; +import { StudentsList } from '@/components/professional/dashboard/StudentsList'; +import { OverviewMock } from '@/components/professional/dashboard/Overview'; +import { WorkoutsMock } from '@/components/professional/dashboard/Workouts'; +import { PlaceholderModule } from '@/components/professional/common/PlaceholderModule'; + +interface ProfessionalDashboardProps { + user: User; + onExit: () => void; + onLogout: () => void; +} + +type Tab = 'overview' | 'students' | 'workouts' | 'assessments' | 'library' | 'financial' | 'settings'; + +const ProfessionalDashboard: React.FC = ({ user, onExit, onLogout }) => { + const [activeTab, setActiveTab] = useState('overview'); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + return ( +
+ + {/* Mobile Overlay */} + {isSidebarOpen && ( +
setIsSidebarOpen(false)} + /> + )} + + {/* Sidebar (SaaS Style - Dark) */} + + + {/* Main Content Area */} +
+ + {/* Top Header */} +
+
+ +

{activeTab === 'overview' ? 'Visão Geral' : activeTab}

+
+ +
+ + +
+
+ +
+ {/* Dynamic Content */} + {activeTab === 'overview' && } + {activeTab === 'students' && } + {activeTab === 'workouts' && } + {activeTab === 'assessments' && } />} + {activeTab === 'library' && } />} + {activeTab === 'financial' && } />} +
+ +
+
+ ); +}; + +const NavItem = ({ icon, label, active, onClick }: any) => ( + +); + +export default ProfessionalDashboard; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..aea3dd1 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,12 @@ +export interface User { + id: string; + name: string; + email: string; + phone?: string; + plan: 'free' | 'pro' | 'trial'; + public_id: string; + avatar?: string; + plan_valid_until?: string; + is_admin?: boolean; + is_professional?: boolean; +} diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest new file mode 100644 index 0000000..8c68db7 --- /dev/null +++ b/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.67.1 \ No newline at end of file diff --git a/supabase/.temp/gotrue-version b/supabase/.temp/gotrue-version new file mode 100644 index 0000000..e78dcd1 --- /dev/null +++ b/supabase/.temp/gotrue-version @@ -0,0 +1 @@ +v2.184.0 \ No newline at end of file diff --git a/supabase/.temp/pooler-url b/supabase/.temp/pooler-url new file mode 100644 index 0000000..5c58069 --- /dev/null +++ b/supabase/.temp/pooler-url @@ -0,0 +1 @@ +postgresql://postgres.mnhgpnqkwuqzpvfrwftp@aws-1-sa-east-1.pooler.supabase.com:5432/postgres \ No newline at end of file diff --git a/supabase/.temp/postgres-version b/supabase/.temp/postgres-version new file mode 100644 index 0000000..18f1b99 --- /dev/null +++ b/supabase/.temp/postgres-version @@ -0,0 +1 @@ +17.6.1.054 \ No newline at end of file diff --git a/supabase/.temp/project-ref b/supabase/.temp/project-ref new file mode 100644 index 0000000..9a5d561 --- /dev/null +++ b/supabase/.temp/project-ref @@ -0,0 +1 @@ +mnhgpnqkwuqzpvfrwftp \ No newline at end of file diff --git a/supabase/.temp/rest-version b/supabase/.temp/rest-version new file mode 100644 index 0000000..93c142b --- /dev/null +++ b/supabase/.temp/rest-version @@ -0,0 +1 @@ +v13.0.5 \ No newline at end of file diff --git a/supabase/.temp/storage-migration b/supabase/.temp/storage-migration new file mode 100644 index 0000000..5a41722 --- /dev/null +++ b/supabase/.temp/storage-migration @@ -0,0 +1 @@ +buckets-objects-grants-postgres \ No newline at end of file diff --git a/supabase/.temp/storage-version b/supabase/.temp/storage-version new file mode 100644 index 0000000..89c19be --- /dev/null +++ b/supabase/.temp/storage-version @@ -0,0 +1 @@ +v1.33.0 \ No newline at end of file diff --git a/supabase/functions/coach-generator/index.ts b/supabase/functions/coach-generator/index.ts new file mode 100644 index 0000000..33d955c --- /dev/null +++ b/supabase/functions/coach-generator/index.ts @@ -0,0 +1,115 @@ + +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { COACH_SYSTEM_PROMPT } from "./prompt.ts"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + // Handle CORS preflight requests + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + const { photos, goal } = await req.json(); + + if (!photos || (!photos.front && !photos.side && !photos.back)) { + throw new Error("Pelo menos uma foto é necessária."); + } + + const GEMINI_API_KEY = Deno.env.get("GEMINI_API_KEY"); + if (!GEMINI_API_KEY) { + throw new Error("Servidor não configurado (API Key ausente)."); + } + + // Prepare Image Parts + const parts = []; + + // System Prompt + parts.push({ text: COACH_SYSTEM_PROMPT }); + + // User Goal + parts.push({ text: `Objetivo do Usuário: ${goal}\nAnalise as fotos e gere o protocolo.` }); + + // Images + for (const [key, value] of Object.entries(photos)) { + if (typeof value === 'string' && value.includes('base64,')) { + // value example: "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + const base64Data = value.split(',')[1]; + // Detect mime type + const mimeMatch = value.match(/^data:(.*);base64/); + const mimeType = mimeMatch ? mimeMatch[1] : 'image/jpeg'; + + parts.push({ + inline_data: { + mime_type: mimeType, + data: base64Data + } + }); + } + } + + // Call Gemini API via Fetch (More stable than SDK in Deno Edge) + // Using user-specified model: gemini-2.5-flash + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + contents: [{ parts: parts }], + generationConfig: { + temperature: 0.2, + response_mime_type: "application/json" + } + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Gemini API Error:", errorText); + throw new Error(`Erro na IA (${response.status}): ${errorText}`); + } + + const data = await response.json(); + const generatedText = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!generatedText) { + console.error("Gemini Empty Response:", JSON.stringify(data)); + throw new Error("A IA não conseguiu analisar as imagens. Tente fotos com melhor iluminação."); + } + + let jsonResponse; + try { + // Clean markdown blocks if present (common in Gemini responses) + const cleaned = generatedText.replace(/```json/g, '').replace(/```/g, '').trim(); + jsonResponse = JSON.parse(cleaned); + } catch (e) { + console.error("JSON Parse Error:", generatedText); + throw new Error("Erro ao processar a resposta da IA. Tente novamente."); + } + + // Basic validation of the response structure + if (!jsonResponse.analysis || !jsonResponse.diet || !jsonResponse.workout) { + throw new Error("A resposta da IA veio incompleta. Por favor, tente novamente."); + } + + return new Response(JSON.stringify(jsonResponse), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200 + }); + + } catch (error) { + console.error("Function Error:", error); + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400 // Return 400 so client sees it as error, but with body + }); + } +}); diff --git a/supabase/functions/coach-generator/prompt.ts b/supabase/functions/coach-generator/prompt.ts new file mode 100644 index 0000000..630bc0f --- /dev/null +++ b/supabase/functions/coach-generator/prompt.ts @@ -0,0 +1,100 @@ +export const COACH_SYSTEM_PROMPT = ` +Você é o "Titan Coach", um treinador olímpico de elite e nutricionista esportivo PhD. +Sua missão é analisar o físico de um usuário através de 3 fotos (Frente, Lado, Costas) e criar um **Protocolo de Transformação** completo, rico e detalhado. + +RETORNE APENAS JSON. +NÃO use Markdown. +Formato de Resposta (Siga estritamente esta estrutura): + +{ + "analysis": { + "body_fat_percentage": 0, + "somatotype": "Ectomorfo" | "Mesomorfo" | "Endomorfo", + "muscle_mass_level": "Baixo" | "Médio" | "Alto", + "posture_analysis": "Texto detalhado sobre postura (ex: leve cifose, lordose, desvios laterais)", + "strengths": ["Ombros largos", "Cintura fina", "Bons quadríceps"], + "weaknesses": ["Panturrilhas pouco desenvolvidas", "Peitoral superior fraco"] + }, + "diet": { + "total_calories": 0, + "macros": { + "protein_g": 0, + "carbs_g": 0, + "fats_g": 0 + }, + "hydration_liters": 0, + "supplements": [ + { "name": "Creatina", "dosage": "5g pós-treino", "reason": "Aumento de força e recuperação" }, + { "name": "Whey Protein", "dosage": "30g se não bater a meta", "reason": "Praticidade para bater proteínas" }, + { "name": "Multivitamínico", "dosage": "1 caps almoço", "reason": "Micro-nutrientes essenciais" } + ], + "meal_plan_example": [ + { + "name": "Café da Manhã", + "time_range": "07:00 - 08:00", + "options": [ + "Opção 1: 3 Ovos mexidos + 1 Banana + 40g Aveia", + "Opção 2: 2 Fatias Pão Integral + 100g Frango Desfiado + Queijo Cotagge" + ], + "substitution_suggestion": "Para vegetarianos: Trocar frango por Tofu ou ovos por Shake proteico vegano." + }, + { + "name": "Almoço", + "time_range": "12:00 - 13:00", + "options": [ + "Opção 1: 150g Frango Grelhado + 120g Arroz Branco + Vegetais Verdes à vontade", + "Opção 2: 150g Patinho Moído + 150g Batata Inglesa + Salada Mista" + ], + "substitution_suggestion": "Se enjoar de arroz, use Macarrão Integral (mesmo peso) ou Batata Doce (peso x1.3)." + }, + { + "name": "Lanche da Tarde", + "time_range": "16:00 - 16:30", + "options": [ + "Opção 1: 1 Iogurte Grego Zero + 20g Nozes", + "Opção 2: 1 Fruta + 1 Dose de Whey" + ], + "substitution_suggestion": "Pode trocar as gorduras (nozes) por Pasta de Amendoim." + }, + { + "name": "Jantar", + "time_range": "20:00 - 21:00", + "options": [ + "Opção 1: 150g Peixe Branco (Tilápia) + Salada Completa + Azeite de Oliva", + "Opção 2: Omelete de 3 Ovos com Espinafre e Tomate" + ], + "substitution_suggestion": "Evite carboidratos pesados a noite se o objetivo for secar." + } + ] + }, + "workout": { + "split": "ABC" | "ABCD" | "ABCDE" | "Fullbody", + "focus": "Hipertrofia" | "Força" | "Perda de Gordura", + "frequency_days": 0, + "injury_adaptations": { + "knee_pain": "Substituir Agachamento por Leg Press 45 com pés altos", + "shoulder_pain": "Fazer Supino com Halteres pegada neutra ao invés de barra", + "back_pain": "Evitar Terra e Remada Curvada, preferir máquinas apoiadas" + }, + "routine": [ + { + "day": "Segunda", + "muscle_group": "Peito + Tríceps", + "exercises": [ + { "name": "Supino Inclinado com Halteres", "sets": 4, "reps": "8-12", "technique": "Focar na parte superior, descida controlada" }, + { "name": "Crucifixo Máquina", "sets": 3, "reps": "12-15", "technique": "Pico de contração de 1s" } + ] + } + ] + }, + "motivation_quote": "Uma frase curta de impacto." +} + +Regras IMPORTANTES: +1. Seja MUITO DETALHADO na dieta. Dê SEMPRE pelo menos 2 opções para CADA refeição ("options"). +2. Inclua o horário sugerido ("time_range") para cada refeição. +3. O campo "substitution_suggestion" deve dar uma alternativa clara de troca de alimentos (ex: trocar carbo X por Y). +4. Adapte o treino ao biotipo (ex: Ectomorfo menos volume, Endomorfo mais cardio). +5. Nos suplementos, especifique COMO tomar e PORQUE. +6. A resposta DEVE ser um JSON válido. +`; diff --git a/supabase/functions/stripe-checkout/index.ts b/supabase/functions/stripe-checkout/index.ts new file mode 100644 index 0000000..b5df17b --- /dev/null +++ b/supabase/functions/stripe-checkout/index.ts @@ -0,0 +1,125 @@ + +/// +import Stripe from "https://esm.sh/stripe@16.12.0?target=deno"; + +const STRIPE_SECRET_KEY = Deno.env.get("STRIPE_SECRET_KEY")!; +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!; +const SITE_URL = Deno.env.get("SITE_URL")!; + +// ✅ seus PRICE IDs (recorrentes) +const PRICE_MENSAL = "price_1SeOVpPHwVDouhbBWZj9beS3"; +const PRICE_TRIMESTRAL = "price_1SeOeXPHwVDouhbBcaiUy3vu"; +const PRICE_ANUAL = "price_1SeOg4PHwVDouhbBTEiUPhMl"; + +const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-11-17.clover" }); + +const corsHeaders = { + "access-control-allow-origin": "*", + "access-control-allow-headers": "authorization, x-client-info, apikey, content-type", + "access-control-allow-methods": "POST, OPTIONS", +}; + +function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json", ...corsHeaders }, + }); +} + +async function getUserFromJwt(jwt: string) { + const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, { + headers: { + apikey: SUPABASE_ANON_KEY, + authorization: `Bearer ${jwt}`, + }, + }); + if (!res.ok) return null; + return await res.json(); +} + +function assertBaseUrl(raw: string, name: string) { + let u: URL; + try { + u = new URL(raw); + } catch { + throw new Error(`${name} inválida. Use https://... (ex: https://foodsnap.com.br)`); + } + if (u.protocol !== "https:" && u.hostname !== "localhost") { + throw new Error(`${name} deve ser https:// (ou localhost em dev)`); + } + return u; +} + +function normalizePlan(planRaw: unknown) { + const p = String(planRaw ?? "").toLowerCase().trim(); + if (p === "mensal" || p === "monthly") return "mensal"; + if (p === "trimestral" || p === "quarterly") return "trimestral"; + if (p === "anual" || p === "annual" || p === "yearly") return "anual"; + return ""; +} + +function priceIdForPlan(plan: string) { + if (plan === "mensal") return PRICE_MENSAL; + if (plan === "trimestral") return PRICE_TRIMESTRAL; + if (plan === "anual") return PRICE_ANUAL; + return null; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ ok: false, error: "Method not allowed" }, 405); + + try { + if (!STRIPE_SECRET_KEY) return json({ ok: false, error: "Missing STRIPE_SECRET_KEY" }, 500); + if (!SUPABASE_URL) return json({ ok: false, error: "Missing SUPABASE_URL" }, 500); + if (!SUPABASE_ANON_KEY) return json({ ok: false, error: "Missing SUPABASE_ANON_KEY" }, 500); + if (!SITE_URL) return json({ ok: false, error: "Missing SITE_URL" }, 500); + + const site = assertBaseUrl(SITE_URL, "SITE_URL"); + + const auth = req.headers.get("authorization") || ""; + const jwt = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + if (!jwt) return json({ ok: false, error: "Missing Authorization Bearer token" }, 401); + + const user = await getUserFromJwt(jwt); + if (!user?.id) return json({ ok: false, error: "Invalid token" }, 401); + + const body = await req.json().catch(() => ({})); + const plan = normalizePlan(body?.plan); + if (!plan) return json({ ok: false, error: "Plano inválido. Use: mensal|trimestral|anual" }, 400); + + const priceId = priceIdForPlan(plan); + if (!priceId) return json({ ok: false, error: "Price não configurado para este plano" }, 500); + + // ✅ garante que é recorrente + const price = await stripe.prices.retrieve(priceId); + const isRecurring = (price as any)?.type === "recurring" || !!(price as any)?.recurring; + if (!isRecurring) { + return json( + { ok: false, error: `O price ${priceId} não é recorrente. Precisa ser Recurring para subscription.` }, + 400, + ); + } + + const successUrl = new URL("/dashboard?checkout=success", site).toString(); + const cancelUrl = new URL("/dashboard?checkout=cancel", site).toString(); + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + success_url: successUrl, + cancel_url: cancelUrl, + + // ✅ amarra no usuário + customer_email: user.email ?? undefined, + metadata: { user_id: user.id, plan_code: plan }, + subscription_data: { metadata: { user_id: user.id, plan_code: plan } }, + }); + + return json({ ok: true, url: session.url, plan, priceId }); + } catch (err) { + console.error("stripe-checkout error:", err); + return json({ ok: false, error: String((err as any)?.message ?? err) }, 500); + } +}); diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts new file mode 100644 index 0000000..10213b8 --- /dev/null +++ b/supabase/functions/stripe-webhook/index.ts @@ -0,0 +1,299 @@ + +/// + +import Stripe from "npm:stripe@16.12.0"; + +type EntitlementCode = "free" | "mensal" | "trimestral" | "anual" | "pro" | "trial"; + +const STRIPE_SECRET_KEY = Deno.env.get("STRIPE_SECRET_KEY") ?? ""; +const STRIPE_WEBHOOK_SECRET = Deno.env.get("STRIPE_WEBHOOK_SECRET") ?? ""; + +// ✅ nomes oficiais no Supabase Edge +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + +const REQUIRED_OK = !!( + STRIPE_SECRET_KEY && + STRIPE_WEBHOOK_SECRET && + SUPABASE_URL && + SUPABASE_SERVICE_ROLE_KEY +); + +const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-11-17.clover" }); + +function json(data: unknown, status = 200, extraHeaders: Record = {}) { + return new Response(JSON.stringify(data), { + status, + headers: { + "content-type": "application/json", + ...extraHeaders, + }, + }); +} + +function corsHeaders(origin: string | null) { + const allowOrigin = origin ?? "*"; + return { + "Access-Control-Allow-Origin": allowOrigin, + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type, stripe-signature", + "Access-Control-Allow-Methods": "POST, OPTIONS", + }; +} + +async function supabaseAdmin(path: string, init?: RequestInit) { + const url = `${SUPABASE_URL}${path}`; + return fetch(url, { + ...init, + headers: { + "content-type": "application/json", + apikey: SUPABASE_SERVICE_ROLE_KEY, + authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`, + ...(init?.headers || {}), + }, + }); +} + +async function upsertStripeCustomer( + user_id: string, + stripe_customer_id: string, + email?: string | null, +) { + const res = await supabaseAdmin(`/rest/v1/stripe_customers?on_conflict=user_id`, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates" }, + body: JSON.stringify({ + user_id, + stripe_customer_id, + email: email ?? null, + updated_at: new Date().toISOString(), + }), + }); + + if (!res.ok) { + const t = await res.text(); + throw new Error(`stripe_customers upsert failed: ${res.status} ${t}`); + } +} + +async function upsertEntitlement( + user_id: string, + entitlement_code: EntitlementCode, + is_active: boolean, + valid_until: string | null, +) { + const res = await supabaseAdmin(`/rest/v1/user_entitlements?on_conflict=user_id`, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates" }, + body: JSON.stringify({ + user_id, + entitlement_code, + is_active, + valid_until, + updated_at: new Date().toISOString(), + }), + }); + + if (!res.ok) { + const t = await res.text(); + throw new Error(`user_entitlements upsert failed: ${res.status} ${t}`); + } +} + +function safePlanCode(v: unknown): EntitlementCode { + const s = String(v ?? "").toLowerCase().trim(); + if (s === "mensal" || s === "trimestral" || s === "anual" || s === "pro" || s === "trial" || s === "free") { + return s; + } + return "free"; +} + +function secondsToISO(sec?: number | null) { + if (!sec || !Number.isFinite(sec)) return null; + return new Date(sec * 1000).toISOString(); +} + +/** + * ✅ Correção do valid_until: + * Em alguns payloads, `current_period_end` NÃO vem no root da subscription. + * Ele vem em `items.data[0].current_period_end`. + */ +function getPeriodEndISO(sub: Stripe.Subscription) { + const sec = + (sub as any).current_period_end ?? + (sub as any)?.items?.data?.[0]?.current_period_end ?? + null; + + return secondsToISO(sec); +} + +async function resolveUserId(customerId?: string | null, metadataUserId?: string | null) { + if (metadataUserId) return metadataUserId; + if (!customerId) return null; + + const q = new URLSearchParams(); + q.set("stripe_customer_id", `eq.${customerId}`); + q.set("select", "user_id"); + q.set("limit", "1"); + + const res = await supabaseAdmin(`/rest/v1/stripe_customers?${q.toString()}`, { method: "GET" }); + if (!res.ok) return null; + + const rows = await res.json(); + return rows?.[0]?.user_id ?? null; +} + +Deno.serve(async (req) => { + const origin = req.headers.get("origin"); + const cors = corsHeaders(origin); + + // Preflight (não é obrigatório pro Stripe, mas não atrapalha) + if (req.method === "OPTIONS") return new Response("ok", { headers: cors }); + + if (!REQUIRED_OK) { + console.error("Missing required env vars.", { + hasStripeKey: !!STRIPE_SECRET_KEY, + hasWhsec: !!STRIPE_WEBHOOK_SECRET, + hasSbUrl: !!SUPABASE_URL, + hasSr: !!SUPABASE_SERVICE_ROLE_KEY, + }); + return json({ ok: false, error: "Missing required env vars" }, 500, cors); + } + + // Stripe manda POST + if (req.method !== "POST") return json({ ok: false, error: "Method not allowed" }, 405, cors); + + const sig = req.headers.get("stripe-signature") ?? ""; + if (!sig) return json({ ok: false, error: "Missing stripe-signature" }, 400, cors); + + const raw = await req.text(); + + let event: Stripe.Event; + try { + event = await stripe.webhooks.constructEventAsync(raw, sig, STRIPE_WEBHOOK_SECRET); + } catch (err) { + console.error("Webhook signature verification failed:", err); + return json({ ok: false, error: "Invalid signature" }, 400, cors); + } + + try { + const t = event.type; + + // 1) Checkout finalizado + if (t === "checkout.session.completed") { + const s = event.data.object as Stripe.Checkout.Session; + + const customerId = (s.customer as string | null) ?? null; + const userId = await resolveUserId(customerId, (s.metadata?.user_id as string | undefined) ?? null); + if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors); + + const plan = safePlanCode(s.metadata?.plan_code); + const email = (s.customer_details?.email ?? s.customer_email ?? null) as string | null; + + if (customerId) await upsertStripeCustomer(userId, customerId, email); + + // ✅ tenta já trazer o valid_until buscando a subscription (quando existir) + let validUntil: string | null = null; + if (s.subscription) { + const sub = await stripe.subscriptions.retrieve(String(s.subscription)); + validUntil = getPeriodEndISO(sub) ?? null; + } + + await upsertEntitlement(userId, plan, true, validUntil); + return json({ ok: true }, 200, cors); + } + + // 2) Subscription é a fonte da verdade + if (t === "customer.subscription.created" || t === "customer.subscription.updated") { + const sub = event.data.object as Stripe.Subscription; + + const customerId = (sub.customer as string | null) ?? null; + const userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null); + if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors); + + const plan = safePlanCode(sub.metadata?.plan_code); + const isActive = sub.status === "active" || sub.status === "trialing"; + const validUntil = getPeriodEndISO(sub) ?? null; + + if (customerId) await upsertStripeCustomer(userId, customerId, null); + await upsertEntitlement(userId, plan, isActive, validUntil); + + return json({ ok: true, plan, isActive, validUntil }, 200, cors); + } + + // 3) Pause/Delete: volta pro free + if (t === "customer.subscription.paused" || t === "customer.subscription.deleted") { + const sub = event.data.object as Stripe.Subscription; + + const customerId = (sub.customer as string | null) ?? null; + const userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null); + if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors); + + await upsertEntitlement(userId, "free", false, null); + return json({ ok: true }, 200, cors); + } + + // 4) Pagamento Confirmado (Salvar no Histórico) + if (t === "invoice.payment_succeeded") { + const invoice = event.data.object as Stripe.Invoice; + const customerId = (invoice.customer as string | null) ?? null; + + // Tenta pegar user_id do metadata da subscription ou do cliente + let userId = await resolveUserId(customerId, null); + + // Fallback: Tenta pegar da subscription associada à invoice + if (!userId && invoice.subscription) { + try { + const sub = await stripe.subscriptions.retrieve(String(invoice.subscription)); + userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null); + } catch (e) { + console.error("Error retrieving subscription for userId fallback:", e); + } + } + + if (!userId) { + console.error("Invoice payment succeeded but could not resolve userId", { customerId, invoiceId: invoice.id }); + return json({ ok: true, skipped: true, reason: "no_user_id_for_invoice" }, 200, cors); + } + + // Mapeia dados + const amount = (invoice.amount_paid || 0) / 100; // Centavos para Real + const currency = invoice.currency; + const status = "completed"; + const method = invoice.collection_method === "charge_automatically" ? "credit_card" : "other"; // Simplificado + + // Tenta adivinhar o plano pelo valor ou linhas da fatura (básico) + // Idealmente viria do metadata, mas na invoice pode ser mais chato de pegar sem chamada extra + const lines = invoice.lines?.data || []; + const planDescription = lines.length > 0 ? lines[0].description : "Assinatura"; + let planType = "monthly"; + if (planDescription?.toLowerCase().includes("anual")) planType = "yearly"; + if (planDescription?.toLowerCase().includes("trimestral")) planType = "quarterly"; + + // Insere na tabela payments + const { error: payErr } = await supabaseAdmin(`/rest/v1/payments`, { + method: "POST", + body: JSON.stringify({ + user_id: userId, + amount: amount, + status: status, + plan_type: planType, + payment_method: method, + created_at: new Date().toISOString() + }), + }); + + if (payErr) { + // Loga erro mas não retorna 500 para não travar o webhook do Stripe (que tentaria reenviar) + console.error("Error inserting payment record:", payErr); + } + + return json({ ok: true, message: "Payment recorded" }, 200, cors); + } + + return json({ ok: true, ignored: true, type: t }, 200, cors); + } catch (err) { + console.error("stripe-webhook handler error:", err); + return json({ ok: false, error: String((err as any)?.message ?? err) }, 500, cors); + } +}); diff --git a/supabase/functions/validate-access/index.ts b/supabase/functions/validate-access/index.ts new file mode 100644 index 0000000..712e80b --- /dev/null +++ b/supabase/functions/validate-access/index.ts @@ -0,0 +1,117 @@ + +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + // Handle CORS preflight requests + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + // 1. Initialize Supabase Client with the incoming user's Auth context + // This allows us to use `auth.getUser()` securely based on the JWT sent by the frontend. + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { + global: { + headers: { Authorization: req.headers.get('Authorization')! }, + }, + } + ); + + // 2. Get User from Token + const { + data: { user }, + error: authError, + } = await supabaseClient.auth.getUser(); + + if (authError || !user) { + return new Response( + JSON.stringify({ allowed: false, error: 'Unauthorized', reason: 'auth_failed' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // 3. Check Entitlements (Active Plan?) + // We look for the most recent entitlement that is active. + const { data: entitlement, error: entError } = await supabaseClient + .from('user_entitlements') + .select('is_active, valid_until, entitlement_code') + .eq('user_id', user.id) + .order('valid_until', { ascending: false }) + .maybeSingle(); + + if (entError) { + console.error("Entitlement check error:", entError); + } + + // A plan is active if is_active=true AND (valid_until is NULL (lifetime) OR valid_until > now) + const isActive = entitlement?.is_active && (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date()); + + if (isActive) { + return new Response( + JSON.stringify({ + allowed: true, + plan: entitlement.entitlement_code, + reason: 'plan_active', + quota_remaining: -1 // Infinite/Plan + }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // 4. Check Free Quota (Coach Analyses) + // Counts how many analyses already consumed the free quota. + const { count, error: countError } = await supabaseClient + .from('coach_analyses') + .select('*', { count: 'exact', head: true }) + .eq('user_id', user.id) + .eq('used_free_quota', true); + + if (countError) { + console.error("Quota check error:", countError); + throw new Error("Failed to check quota usage."); + } + + const FREE_LIMIT = 3; // Defined limit for Coach + const used = count || 0; + const remaining = Math.max(0, FREE_LIMIT - used); + + if (remaining > 0) { + return new Response( + JSON.stringify({ + allowed: true, + plan: 'free', + reason: 'free_quota', + quota_remaining: remaining + }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // 5. Quota Exceeded + return new Response( + JSON.stringify({ + allowed: false, + plan: 'free', + reason: 'quota_exceeded', + quota_remaining: 0 + }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } // 200 OK because logic was successful, just access denied + ); + + } catch (error: any) { + console.error("Validate Access Error:", error); + return new Response( + JSON.stringify({ allowed: false, error: error.message }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +}); diff --git a/supabase/functions/whatsapp-webhook/index.ts b/supabase/functions/whatsapp-webhook/index.ts new file mode 100644 index 0000000..599cc7e --- /dev/null +++ b/supabase/functions/whatsapp-webhook/index.ts @@ -0,0 +1,954 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7"; +import { GoogleGenerativeAI } from "https://esm.sh/@google/generative-ai@0.21.0"; +import { SYSTEM_PROMPT, COACH_SYSTEM_PROMPT } from "./prompt.ts"; +import { buildCoachPdfHtml } from "./pdf-template.ts"; + +// ─── Config ──────────────────────────────────────────────────────── +const EVOLUTION_API_URL = Deno.env.get("EVOLUTION_API_URL") ?? ""; +const EVOLUTION_API_KEY = Deno.env.get("EVOLUTION_API_KEY") ?? ""; +const GEMINI_API_KEY = Deno.env.get("GEMINI_API_KEY") ?? ""; +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const SUPABASE_SRK = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + +const INSTANCE_NAME = "foodsnap"; +const FREE_FOOD_LIMIT = 5; + +// ─── Types ───────────────────────────────────────────────────────── +interface EvolutionPayload { + event: string; + instance: string; + data: { + key: { remoteJid: string; fromMe: boolean; id: string }; + pushName?: string; + messageType?: string; + messageTimestamp?: number; + message?: { + imageMessage?: { mimetype: string }; + conversation?: string; + extendedTextMessage?: { text: string }; + }; + }; + sender?: string; +} + +// ─── Helpers ─────────────────────────────────────────────────────── + +/** Remove tudo que não é dígito */ +const onlyDigits = (s: string) => s.replace(/\D/g, ""); + +/** + * Gera candidatos de número brasileiro (com/sem DDI 55, com/sem 9º dígito). + * Usado para fazer match com profiles.phone_e164 e profiles.phone. + */ +function generatePhoneCandidates(raw: string): string[] { + const candidates: string[] = []; + const num = onlyDigits(raw); + if (!num) return candidates; + + candidates.push(num); + + const withoutDDI = num.startsWith("55") ? num.slice(2) : num; + if (withoutDDI !== num) candidates.push(withoutDDI); + if (!num.startsWith("55")) candidates.push("55" + num); + + const ddd = withoutDDI.slice(0, 2); + const rest = withoutDDI.slice(2); + + // Adiciona 9º dígito se tem 8 dígitos após DDD + if (rest.length === 8) { + const with9 = ddd + "9" + rest; + candidates.push(with9); + candidates.push("55" + with9); + } + + // Remove 9º dígito se tem 9 dígitos após DDD + if (rest.length === 9 && rest.startsWith("9")) { + const without9 = ddd + rest.slice(1); + candidates.push(without9); + candidates.push("55" + without9); + } + + return candidates; +} + +/** Envia mensagem de texto via Evolution API */ +async function sendWhatsAppMessage(remoteJid: string, text: string) { + if (!EVOLUTION_API_URL) { + console.error("[WH] EVOLUTION_API_URL not set! Cannot send message."); + return; + } + try { + const url = `${EVOLUTION_API_URL}/message/sendText/${INSTANCE_NAME}`; + console.log(`[WH] Sending message to ${remoteJid.slice(0, 8)}... via ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", apikey: EVOLUTION_API_KEY }, + body: JSON.stringify({ + number: remoteJid, + text: text, + delay: 1200, + }), + }); + const resBody = await res.text(); + console.log(`[WH] Evolution API response: ${res.status} ${resBody.slice(0, 200)}`); + } catch (err) { + console.error("[WH] Error sending WhatsApp message:", err); + } +} + +/** Envia documento (PDF) via Evolution API */ +async function sendWhatsAppDocument(remoteJid: string, mediaUrl: string, fileName: string, caption?: string) { + if (!EVOLUTION_API_URL) { + console.error("[WH] EVOLUTION_API_URL not set! Cannot send document."); + return; + } + try { + const url = `${EVOLUTION_API_URL}/message/sendMedia/${INSTANCE_NAME}`; + console.log(`[WH] Sending document to ${remoteJid.slice(0, 8)}... file=${fileName}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", apikey: EVOLUTION_API_KEY }, + body: JSON.stringify({ + number: remoteJid, + mediatype: "document", + media: mediaUrl, + fileName: fileName, + caption: caption || "", + delay: 1200, + }), + }); + const resBody = await res.text(); + console.log(`[WH] Evolution sendMedia response: ${res.status} ${resBody.slice(0, 200)}`); + } catch (err) { + console.error("[WH] Error sending WhatsApp document:", err); + } +} + +/** Busca imagem em base64 da Evolution API */ +async function getWhatsAppMedia(messageId: string): Promise { + if (!EVOLUTION_API_URL) { + console.error("[WH] EVOLUTION_API_URL not set for media download!"); + return null; + } + try { + const url = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${INSTANCE_NAME}`; + console.log(`[WH] Fetching media: ${url}, messageId=${messageId}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", apikey: EVOLUTION_API_KEY }, + body: JSON.stringify({ + message: { key: { id: messageId } }, + convertToMp4: false, + }), + }); + const resText = await res.text(); + console.log(`[WH] Media API response: ${res.status} ${resText.slice(0, 300)}`); + + if (!res.ok) return null; + + const data = JSON.parse(resText); + // A API pode retornar em diferentes formatos + const base64 = data.base64 || data.data?.base64 || null; + console.log(`[WH] Got base64: ${base64 ? `${base64.length} chars` : "NULL"}`); + return base64; + } catch (err) { + console.error("[WH] Error fetching media:", err); + return null; + } +} + +/** Converte base64 → Uint8Array (para upload storage) */ +function base64ToUint8Array(base64: string): Uint8Array { + const bin = atob(base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +// ─── Geração de HTML para PDF do Coach ──────────────────────────── +// (Movido para pdf-template.ts) + +// ─── Normalização e limpeza do JSON do Gemini (portado do n8n) ──── + +const toNum = (v: unknown): number => { + if (typeof v === "number") return v; + if (typeof v === "string") { + const n = Number(v.replace(",", ".").trim()); + return Number.isFinite(n) ? n : 0; + } + return 0; +}; + +const ensureArray = (v: unknown): any[] => (Array.isArray(v) ? v : []); + +const keyName = (s: string) => + (s || "") + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); + +const clampConfidence = (c: string) => { + const k = keyName(c); + if (k.includes("alta")) return "alta"; + if (k.includes("baixa")) return "baixa"; + return "media"; +}; + +const CITRUS_VARIANTS = /^(tangerina|bergamota|mandarina|clementina|mexerica)/; + +const CANONICAL_MAP = [ + { match: /^laranja/, canonical: "Laranja" }, + { match: /^banana/, canonical: "Banana" }, + { match: /^maca|^maçã/, canonical: "Maçã" }, + { match: /^pera/, canonical: "Pera" }, + { match: /^uva/, canonical: "Uva" }, + { match: /^abacaxi/, canonical: "Abacaxi" }, + { match: /^melancia/, canonical: "Melancia" }, + { match: /^melao|^melão/, canonical: "Melão" }, +]; + +function canonicalizeName(name: string): string { + const k = keyName(name); + if (CITRUS_VARIANTS.test(k)) return "Laranja"; + for (const rule of CANONICAL_MAP) { + if (rule.match.test(k)) return rule.canonical; + } + return (name || "").trim(); +} + +const stripCitrusMention = (s: string) => { + const k = keyName(s); + if (/(tangerina|bergamota|mandarina|clementina|mexerica)/.test(k)) { + return s + .replace(/tangerina\/bergamota/gi, "laranja") + .replace(/tangerina|bergamota|mandarina|clementina|mexerica/gi, "laranja") + .trim(); + } + return s; +}; + +const parseUnitsPortion = (portion: string) => { + const p = (portion || "").toLowerCase().replace(",", "."); + const um = p.match(/(\d+)\s*unidades?/); + const g = p.match(/(\d+(\.\d+)?)\s*g/); + return { + units: um ? Number(um[1]) : null, + grams: g ? Math.round(Number(g[1])) : null, + }; +}; + +const buildUnitsPortion = (units: number | null, grams: number | null) => { + const u = units && units > 0 ? units : null; + const g = grams && grams > 0 ? grams : null; + if (u && g) return `${u} unidades (${g}g)`; + if (u) return `${u} unidades`; + if (g) return `${g}g`; + return ""; +}; + +/** + * Recebe o texto cru do Gemini e retorna o objeto normalizado + * (portado do nó "Limpar Resultado" do n8n) + */ +function parseAndCleanGeminiResponse(rawText: string): any { + // Limpa markdown + let cleaned = rawText.replace(/```json/gi, "").replace(/```/g, "").trim(); + + // Extrai JSON + const m = cleaned.match(/\{[\s\S]*\}/); + if (!m) throw new Error("JSON não encontrado na resposta do Gemini."); + let jsonStr = m[0]; + + // Corrige JSON mal formado + jsonStr = jsonStr.replace(/:\s*\+(\d+(\.\d+)?)/g, ": $1"); + jsonStr = jsonStr.replace(/,\s*([}\]])/g, "$1"); + + const parsed = JSON.parse(jsonStr); + + // Normaliza items + parsed.items = ensureArray(parsed.items).map((it: any) => { + const rawName = (it.name || "").trim(); + const k = keyName(rawName); + const flags = ensureArray(it.flags); + const name = canonicalizeName(rawName); + const nextFlags = CITRUS_VARIANTS.test(k) + ? Array.from(new Set([...flags, "tipo_duvidoso"])) + : flags; + + return { + ...it, + name, + portion: (it.portion || "").trim(), + calories: toNum(it.calories), + protein: toNum(it.protein), + carbs: toNum(it.carbs), + fat: toNum(it.fat), + fiber: toNum(it.fiber), + sugar: toNum(it.sugar), + sodium_mg: toNum(it.sodium_mg), + flags: nextFlags, + }; + }); + + // Deduplica por nome + const byName = new Map(); + for (const it of parsed.items) { + const k = keyName(it.name); + if (!k) continue; + + if (!byName.has(k)) { + byName.set(k, it); + continue; + } + + const cur = byName.get(k); + const a = parseUnitsPortion(cur.portion); + const b = parseUnitsPortion(it.portion); + let mergedPortion = cur.portion; + if (a.units !== null || b.units !== null || a.grams !== null || b.grams !== null) { + const units = (a.units || 0) + (b.units || 0); + const grams = (a.grams || 0) + (b.grams || 0); + const rebuilt = buildUnitsPortion(units || null, grams || null); + if (rebuilt) mergedPortion = rebuilt; + } + + byName.set(k, { + ...cur, + portion: mergedPortion, + calories: toNum(cur.calories) + toNum(it.calories), + protein: toNum(cur.protein) + toNum(it.protein), + carbs: toNum(cur.carbs) + toNum(it.carbs), + fat: toNum(cur.fat) + toNum(it.fat), + fiber: toNum(cur.fiber) + toNum(it.fiber), + sugar: toNum(cur.sugar) + toNum(it.sugar), + sodium_mg: toNum(cur.sodium_mg) + toNum(it.sodium_mg), + flags: Array.from( + new Set([...ensureArray(cur.flags), ...ensureArray(it.flags), "deduplicado"]) + ), + }); + } + parsed.items = Array.from(byName.values()); + + // Recalcula totais + const sum = (arr: any[], f: string) => arr.reduce((a: number, b: any) => a + toNum(b[f]), 0); + parsed.total = { + calories: Math.round(sum(parsed.items, "calories")), + protein: +sum(parsed.items, "protein").toFixed(1), + carbs: +sum(parsed.items, "carbs").toFixed(1), + fat: +sum(parsed.items, "fat").toFixed(1), + fiber: +sum(parsed.items, "fiber").toFixed(1), + sugar: +sum(parsed.items, "sugar").toFixed(1), + sodium_mg: Math.round(sum(parsed.items, "sodium_mg")), + }; + + // Outros campos + parsed.health_score = toNum(parsed.health_score); + parsed.confidence = clampConfidence(parsed.confidence || ""); + parsed.assumptions = ensureArray(parsed.assumptions).map(stripCitrusMention); + parsed.questions = ensureArray(parsed.questions); + parsed.insights = ensureArray(parsed.insights).map(stripCitrusMention); + parsed.swap_suggestions = ensureArray(parsed.swap_suggestions); + parsed.next_best_actions = ensureArray(parsed.next_best_actions); + + parsed.tip = + parsed.tip && typeof parsed.tip === "object" + ? parsed.tip + : { title: "", text: "", reason: "" }; + parsed.tip.title = String(parsed.tip.title || ""); + parsed.tip.text = stripCitrusMention(String(parsed.tip.text || "")); + parsed.tip.reason = stripCitrusMention(String(parsed.tip.reason || "")); + + return parsed; +} + +/** + * Formata a análise em mensagem rica para WhatsApp + * (portado do nó "Formatar Resposta WHATS" do n8n) + */ +function formatWhatsAppResponse(analysis: any): string { + if (!analysis || !Array.isArray(analysis.items) || !analysis.items.length) { + return "Não foi possível identificar um alimento válido na imagem."; + } + + const items = analysis.items; + const total = analysis.total || {}; + + const fmt = (n: unknown) => { + if (n === undefined || n === null || n === "") return "—"; + const num = Number(n); + if (!Number.isFinite(num)) return String(n); + return (Math.round(num * 10) / 10).toString(); + }; + + const v = (x: unknown) => (x === undefined || x === null || x === "" ? "—" : x); + const lines: string[] = []; + + lines.push("🥗 *RELATÓRIO PRATOFIT*"); + lines.push(""); + lines.push("*Itens identificados*"); + items.forEach((it: any, idx: number) => { + lines.push(`${idx + 1}) ${v(it.name)} — ${v(it.portion)} — ${fmt(it.calories)} kcal`); + }); + + lines.push(""); + lines.push("*Total do prato*"); + lines.push(`Energia: ${fmt(total.calories)} kcal`); + lines.push(""); + lines.push("*Macronutrientes (total)*"); + lines.push(`Proteínas: ${fmt(total.protein)} g`); + lines.push(`Carboidratos: ${fmt(total.carbs)} g`); + lines.push(`Gorduras: ${fmt(total.fat)} g`); + lines.push(""); + lines.push("*Outros nutrientes (total)*"); + lines.push(`Fibras: ${fmt(total.fiber)} g`); + lines.push(`Açúcares: ${fmt(total.sugar)} g`); + lines.push(`Sódio: ${fmt(total.sodium_mg)} mg`); + + if (analysis.health_score !== undefined) { + lines.push(`Score nutricional: ${fmt(analysis.health_score)} / 100`); + } + if (analysis.confidence) { + lines.push(`Confiabilidade: ${String(analysis.confidence).toLowerCase()}`); + } + + lines.push(""); + + if (analysis.tip && analysis.tip.text) { + lines.push("💡 *Dica prática*"); + lines.push(analysis.tip.text); + } + + return lines.join("\n"); +} + +// ─── Main Handler ────────────────────────────────────────────────── + +serve(async (req) => { + if (req.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + try { + const payload: EvolutionPayload = await req.json(); + + // ── 0. Filtrar eventos irrelevantes ───────────────────────── + const event = payload.event || ""; + console.log(`[WH] Event received: ${event}`); + + const IGNORED_EVENTS = [ + "connection.update", + "qrcode.updated", + "presence.update", + "contacts.update", + "groups.update", + "chats.update", + ]; + if (IGNORED_EVENTS.includes(event)) { + console.log(`[WH] Event ignored: ${event}`); + return new Response("Event ignored", { status: 200 }); + } + + const data = payload.data; + if (!data || !data.key) { + console.log(`[WH] Invalid payload — missing data or data.key`); + return new Response("Invalid payload", { status: 200 }); + } + + const remoteJid = data.key.remoteJid; + + // Ignorar mensagens próprias ou de status + if (data.key.fromMe || remoteJid.includes("status@")) { + console.log(`[WH] Ignored: fromMe=${data.key.fromMe}, jid=${remoteJid}`); + return new Response("Ignored", { status: 200 }); + } + + // ── 1. Extrair dados ──────────────────────────────────────── + const senderNumber = onlyDigits(remoteJid.replace(/@.*$/, "")); + const senderFromPayload = payload.sender + ? onlyDigits(String(payload.sender).replace(/@.*$/, "")) + : ""; + const messageId = data.key.id; + const isImage = !!data.message?.imageMessage; + const textMessage = + data.message?.conversation || data.message?.extendedTextMessage?.text || ""; + + console.log(`[WH] sender=${senderNumber}, isImage=${isImage}, text="${textMessage.slice(0, 50)}"`); + + // Gerar candidatos de número BR + const allCandidates = [ + ...generatePhoneCandidates(senderNumber), + ...(senderFromPayload ? generatePhoneCandidates(senderFromPayload) : []), + ]; + const phoneCandidates = [...new Set(allCandidates)]; + console.log(`[WH] phoneCandidates: ${JSON.stringify(phoneCandidates)}`); + + // ── 2. Init Supabase ──────────────────────────────────────── + const supabase = createClient(SUPABASE_URL, SUPABASE_SRK); + + // ── 3. Buscar usuário com phone_candidates ────────────────── + let user: { id: string } | null = null; + + for (const candidate of phoneCandidates) { + const { data: directMatch, error: matchErr } = await supabase + .from("profiles") + .select("id") + .or(`phone_e164.eq.${candidate},phone.eq.${candidate}`) + .maybeSingle(); + + if (matchErr) { + console.error(`[WH] DB error matching candidate ${candidate}:`, matchErr.message); + } + if (directMatch) { + user = directMatch; + console.log(`[WH] User found: ${user.id} (matched candidate: ${candidate})`); + break; + } + } + + if (!user) { + console.log(`[WH] User NOT found for candidates: ${phoneCandidates.join(", ")}`); + await sendWhatsAppMessage( + remoteJid, + "🚫 *Acesso restrito*\nSeu número não está cadastrado no *FoodSnap*.\n\nCadastre-se em: https://foodsnap.com.br\n\nApós o cadastro, envie novamente a foto do prato 🍽️" + ); + return new Response("User not found", { status: 200 }); + } + + const userId = user.id; + + // ── 4. Estado da conversa (Coach state machine) ───────────── + let { data: conv } = await supabase + .from("whatsapp_conversations") + .select("*") + .eq("phone_number", senderNumber) + .maybeSingle(); + + if (!conv) { + const { data: newConv } = await supabase + .from("whatsapp_conversations") + .insert({ phone_number: senderNumber, state: "IDLE", temp_data: {} }) + .select() + .single(); + conv = newConv; + } + + const state = conv?.state || "IDLE"; + console.log(`[WH] Conversation state: ${state}, conv exists: ${!!conv}`); + + // ── 5. Coach Flow ─────────────────────────────────────────── + + // TRIGGER: texto contendo palavras-chave coach + if ( + state === "IDLE" && + textMessage && + /coach|treino|avalia[çc][aã]o/i.test(textMessage) + ) { + // [LOGIC START] Verificar última avaliação (Limite de 7 dias) + const { data: lastAnalysis } = await supabase + .from("coach_analyses") + .select("created_at") + .eq("user_id", userId) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (lastAnalysis && lastAnalysis.created_at) { + const lastDate = new Date(lastAnalysis.created_at); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - lastDate.getTime()); + const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000; + + if (diffTime < sevenDaysInMs) { + const daysRemaining = Math.ceil((sevenDaysInMs - diffTime) / (1000 * 60 * 60 * 24)); + + await sendWhatsAppMessage( + remoteJid, + `⏳ *Calma, atleta!* O corpo precisa de tempo para evoluir.\n\nSua última avaliação foi há menos de uma semana.\nVocê poderá fazer uma nova avaliação em *${daysRemaining} dia(s)*.\n\nFoque no plano atual! 💪` + ); + return new Response("Coach Cooldown", { status: 200 }); + } + } + // [LOGIC END] + + await supabase + .from("whatsapp_conversations") + .update({ state: "COACH_FRONT", temp_data: {} }) + .eq("phone_number", senderNumber); + + await sendWhatsAppMessage( + remoteJid, + "🏋️‍♂️ *Coach AI Iniciado!*\n\nVamos montar seu protocolo de treino e dieta.\nPara começar, envie uma foto do seu corpo de *FRENTE* (mostrando do pescoço até os joelhos, se possível)." + ); + return new Response("Coach Started", { status: 200 }); + } + + // COACH_FRONT + if (state === "COACH_FRONT") { + if (!isImage) { + await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *FRENTE* para continuarmos."); + return new Response("Waiting Front", { status: 200 }); + } + + const base64 = await getWhatsAppMedia(messageId); + if (!base64) return new Response("Error downloading media", { status: 200 }); + + const fileName = `${userId}_front_${Date.now()}.jpg`; + await supabase.storage + .from("coach-uploads") + .upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" }); + + await supabase + .from("whatsapp_conversations") + .update({ state: "COACH_SIDE", temp_data: { ...conv!.temp_data, front_image: fileName } }) + .eq("phone_number", senderNumber); + + await sendWhatsAppMessage(remoteJid, "✅ Foto de frente recebida!\nAgora, envie uma foto de *LADO* (Perfil)."); + return new Response("Front Received", { status: 200 }); + } + + // COACH_SIDE + if (state === "COACH_SIDE") { + if (!isImage) { + await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *LADO*."); + return new Response("Waiting Side", { status: 200 }); + } + + const base64 = await getWhatsAppMedia(messageId); + if (!base64) return new Response("Error downloading media", { status: 200 }); + + const fileName = `${userId}_side_${Date.now()}.jpg`; + await supabase.storage + .from("coach-uploads") + .upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" }); + + await supabase + .from("whatsapp_conversations") + .update({ state: "COACH_BACK", temp_data: { ...conv!.temp_data, side_image: fileName } }) + .eq("phone_number", senderNumber); + + await sendWhatsAppMessage(remoteJid, "✅ Perfil recebido!\nPor último, envie uma foto de *COSTAS*."); + return new Response("Side Received", { status: 200 }); + } + + // COACH_BACK + if (state === "COACH_BACK") { + if (!isImage) { + await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *COSTAS*."); + return new Response("Waiting Back", { status: 200 }); + } + + const base64 = await getWhatsAppMedia(messageId); + if (!base64) return new Response("Error downloading media", { status: 200 }); + + const fileName = `${userId}_back_${Date.now()}.jpg`; + await supabase.storage + .from("coach-uploads") + .upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" }); + + await supabase + .from("whatsapp_conversations") + .update({ state: "COACH_GOAL", temp_data: { ...conv!.temp_data, back_image: fileName } }) + .eq("phone_number", senderNumber); + + await sendWhatsAppMessage( + remoteJid, + "📸 Todas as fotos recebidas!\n\nAgora digite o número do seu objetivo principal:\n1️⃣ Hipertrofia (Ganhar massa)\n2️⃣ Emagrecimento (Secar)\n3️⃣ Definição (Manter peso/trocar gordura por músculo)" + ); + return new Response("Back Received", { status: 200 }); + } + + // COACH_GOAL + if (state === "COACH_GOAL") { + let goal = "Hipertrofia"; + if (textMessage.includes("2") || /emagreci/i.test(textMessage)) goal = "Emagrecimento"; + else if (textMessage.includes("3") || /defini/i.test(textMessage)) goal = "Definição"; + else if (!textMessage.includes("1") && !/hiper/i.test(textMessage)) { + await sendWhatsAppMessage(remoteJid, "⚠️ Não entendi. Responda com 1, 2 ou 3."); + return new Response("Waiting Goal", { status: 200 }); + } + + await sendWhatsAppMessage( + remoteJid, + "🤖 Estou analisando seu físico e montando o plano com a IA...\nIsso pode levar cerca de 10-15 segundos." + ); + + try { + const { front_image, side_image, back_image } = conv!.temp_data; + const images = [front_image, side_image, back_image]; + const parts: any[] = [{ text: COACH_SYSTEM_PROMPT }, { text: `Objetivo: ${goal}` }]; + + for (const imgPath of images) { + if (imgPath) { + const { data: blob } = await supabase.storage.from("coach-uploads").download(imgPath); + if (blob) { + const buffer = await blob.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + parts.push({ inlineData: { mimeType: "image/jpeg", data: base64 } }); + } + } + } + + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); + + const result = await model.generateContent({ + contents: [{ role: "user", parts }], + generationConfig: { temperature: 0.2, responseMimeType: "application/json" }, + }); + + const responseText = result.response.text(); + const plan = JSON.parse(responseText); + + let msg = `🔥 *SEU PROTOCOLO TITAN* 🔥\n\n`; + msg += `🧬 *Análise*: ${plan.analysis?.somatotype}, ${plan.analysis?.muscle_mass_level} massa muscular.\n`; + msg += `🎯 *Foco*: ${plan.workout?.focus}\n\n`; + msg += `🏋️ *Treino*: Divisão ${plan.workout?.split} (${plan.workout?.frequency_days}x/semana)\n`; + msg += `🥗 *Dieta*: ${Math.round(plan.diet?.total_calories)} kcal\n`; + msg += ` • P: ${plan.diet?.macros?.protein_g}g | C: ${plan.diet?.macros?.carbs_g}g | G: ${plan.diet?.macros?.fats_g}g\n\n`; + msg += `💊 *Suplementos*: ${plan.diet?.supplements?.map((s: any) => s.name).join(", ")}\n\n`; + msg += `💡 *Dica*: ${plan.motivation_quote}\n\n`; + msg += `📲 *Acesse o app para ver o plano completo e detalhado!*`; + + await sendWhatsAppMessage(remoteJid, msg); + + // ── Gerar PDF e enviar via WhatsApp ───────────────── + try { + const pdfFileName = `FoodSnap_Titan_${new Date().toISOString().split("T")[0]}`; + const pdfHtml = buildCoachPdfHtml(plan); + + console.log("[WH] Generating PDF via n8n/Gotenberg..."); + const pdfResponse = await fetch("https://n8n.seureview.com.br/webhook/pdf-coach", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ html: pdfHtml, file_name: pdfFileName }), + }); + + if (pdfResponse.ok) { + const pdfBlob = await pdfResponse.arrayBuffer(); + const pdfBytes = new Uint8Array(pdfBlob); + const storagePath = `${userId}/${pdfFileName}.pdf`; + + // Upload para Supabase Storage + const { error: uploadErr } = await supabase.storage + .from("coach-pdfs") + .upload(storagePath, pdfBytes, { + contentType: "application/pdf", + upsert: true, + }); + + if (uploadErr) { + console.error("[WH] PDF upload error:", uploadErr); + } else { + // URL Assinada (funciona mesmo com bucket privado) + const { data: urlData, error: signErr } = await supabase.storage + .from("coach-pdfs") + .createSignedUrl(storagePath, 60 * 60); // 1 hora de validade + + if (signErr || !urlData?.signedUrl) { + console.error("[WH] Signed URL error:", signErr); + } else { + await sendWhatsAppDocument( + remoteJid, + urlData.signedUrl, + `${pdfFileName}.pdf`, + "📄 Seu Protocolo Titan completo em PDF!" + ); + } + } + } else { + console.error("[WH] n8n PDF error:", pdfResponse.status, await pdfResponse.text()); + } + } catch (pdfErr) { + console.error("[WH] PDF generation/send error (non-blocking):", pdfErr); + // PDF is non-blocking — user already got the text summary + } + + // ── Salvar análise coach (enriquecido p/ dashboard) ─ + const { error: saveCoachErr } = await supabase.from("coach_analyses").insert({ + user_id: userId, + source: "whatsapp", + ai_raw_response: responseText, + ai_structured: plan, + goal_suggestion: goal, + biotype: plan.analysis?.somatotype || null, + estimated_body_fat: parseFloat(String(plan.analysis?.body_fat_percentage || 0)) || 0, + muscle_mass_level: plan.analysis?.muscle_mass_level || null, + }); + + if (saveCoachErr) { + console.error("[WH] Error saving coach analysis to DB:", saveCoachErr); + } else { + console.log("[WH] Coach analysis saved successfully for user:", userId); + } + + // Reset state + await supabase + .from("whatsapp_conversations") + .update({ state: "IDLE", temp_data: {} }) + .eq("phone_number", senderNumber); + } catch (err) { + console.error("Coach Gen Error:", err); + await sendWhatsAppMessage( + remoteJid, + "⚠️ Ocorreu um erro ao gerar seu plano. Tente novamente digitando 'Coach'." + ); + await supabase + .from("whatsapp_conversations") + .update({ state: "IDLE", temp_data: {} }) + .eq("phone_number", senderNumber); + } + + return new Response("Coach Workflow Completed", { status: 200 }); + } + + // ── 6. Food Scan Flow (IDLE) ──────────────────────────────── + if (state === "IDLE") { + console.log(`[WH] Entering Food Scan flow. isImage=${isImage}`); + // 6a. Verificar plano e quota + const { data: entitlement } = await supabase + .from("user_entitlements") + .select("is_active, valid_until, entitlement_code") + .eq("user_id", userId) + .eq("is_active", true) + .order("valid_until", { ascending: false, nullsFirst: false }) + .maybeSingle(); + + const isPaid = + entitlement?.is_active && + (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date()); + + if (!isPaid) { + const { count: freeUsed } = await supabase + .from("food_analyses") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId) + .eq("used_free_quota", true); + + if ((freeUsed || 0) >= FREE_FOOD_LIMIT) { + await sendWhatsAppMessage( + remoteJid, + `🚫 Limite gratuito atingido\nVocê já usou suas ${FREE_FOOD_LIMIT} análises grátis.\n\nPara continuar, assine um plano em:\nhttps://foodsnap.com.br\n\nDepois é só enviar outra foto 📸` + ); + return new Response("Quota exceeded", { status: 200 }); + } + } + + // 6b. Sem imagem → mensagem de boas-vindas + if (!isImage) { + await sendWhatsAppMessage( + remoteJid, + "👋 Olá! Envie uma *foto do seu prato* (bem nítida e de cima 📸) que eu te retorno *calorias e macronutrientes* em segundos.\n\nOu digite *Coach* para iniciar uma consultoria completa." + ); + return new Response("Text handled", { status: 200 }); + } + + // 6c. Processar imagem + await sendWhatsAppMessage(remoteJid, "📸 Recebi sua foto! Estou analisando o prato agora… ⏳"); + + const base64Image = await getWhatsAppMedia(messageId); + if (!base64Image) { + await sendWhatsAppMessage(remoteJid, "⚠️ Não consegui baixar a imagem. Tente enviar novamente."); + return new Response("Error downloading image", { status: 200 }); + } + + // 6d. Chamar Gemini + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); + + const geminiResult = await model.generateContent({ + contents: [ + { + role: "user", + parts: [ + { text: SYSTEM_PROMPT }, + { inlineData: { mimeType: "image/jpeg", data: base64Image } }, + ], + }, + ], + generationConfig: { temperature: 0.1, responseMimeType: "application/json" }, + }); + + const rawResponseText = geminiResult.response.text(); + + // 6e. Limpar e normalizar resultado + let analysis: any; + try { + analysis = parseAndCleanGeminiResponse(rawResponseText); + } catch (parseErr) { + console.error("Parse error:", parseErr); + await sendWhatsAppMessage( + remoteJid, + "⚠️ Houve um erro ao interpretar a análise. Tente enviar a foto novamente com boa iluminação." + ); + return new Response("Parse error", { status: 200 }); + } + + // 6f. Formatar e enviar resposta + const replyText = formatWhatsAppResponse(analysis); + await sendWhatsAppMessage(remoteJid, replyText); + + // 6g. Mapear confidence para enum do banco + const confidenceMap: Record = { + alta: "high", + media: "medium", + média: "medium", + baixa: "low", + }; + + // 6h. Salvar no banco + const { data: inserted } = await supabase + .from("food_analyses") + .insert({ + user_id: userId, + source: "whatsapp", + image_url: null, // será atualizado após upload + ai_raw_response: rawResponseText, + ai_structured: analysis, + total_calories: analysis.total?.calories || 0, + total_protein: analysis.total?.protein || 0, + total_carbs: analysis.total?.carbs || 0, + total_fat: analysis.total?.fat || 0, + total_fiber: analysis.total?.fiber || 0, + total_sodium_mg: analysis.total?.sodium_mg || 0, + nutrition_score: analysis.health_score || 0, + confidence_level: confidenceMap[analysis.confidence] || "medium", + used_free_quota: !isPaid, + }) + .select("id") + .single(); + + // 6i. Upload imagem para Supabase Storage (bucket consultas) + if (inserted?.id) { + try { + const imgPath = `${userId}/${inserted.id}.jpg`; + const imgBytes = base64ToUint8Array(base64Image); + await supabase.storage + .from("consultas") + .upload(imgPath, imgBytes, { contentType: "image/jpeg", upsert: true }); + + // Atualizar image_url no registro + const { data: { publicUrl } } = supabase.storage + .from("consultas") + .getPublicUrl(imgPath); + + await supabase + .from("food_analyses") + .update({ image_url: publicUrl }) + .eq("id", inserted.id); + } catch (uploadErr) { + console.error("Image upload error (non-fatal):", uploadErr); + // Não falha o fluxo principal por erro de upload + } + } + + return new Response("Food Analyzed", { status: 200 }); + } + + return new Response("Nothing happened", { status: 200 }); + } catch (err: any) { + console.error("Critical Error:", err); + return new Response(`Server error: ${err.message}`, { status: 500 }); + } +}); diff --git a/supabase/functions/whatsapp-webhook/pdf-template.ts b/supabase/functions/whatsapp-webhook/pdf-template.ts new file mode 100644 index 0000000..3358ec5 --- /dev/null +++ b/supabase/functions/whatsapp-webhook/pdf-template.ts @@ -0,0 +1,249 @@ + +// ─── Geração de HTML para PDF do Coach (Premium 3 Páginas Compacto) ──────── + +function truncateText(text: string, max = 500): string { + const t = (text || "").trim(); + if (!t) return "-"; + return t.length > max ? t.slice(0, max - 1) + "…" : t; +} + +function safeStr(v: any, fallback = "-"): string { + if (v === null || v === undefined) return fallback; + if (typeof v === "string") return v.trim() || fallback; + if (typeof v === "number") return Number.isFinite(v) ? String(v) : fallback; + return fallback; +} + +export function buildCoachPdfHtml(plan: any): string { + const diet = plan.diet || {}; + const workout = plan.workout || {}; + const analysis = plan.analysis || {}; + const quote = plan.motivation_quote || "Disciplina é a ponte entre metas e conquistas."; + + // --- Data Prep --- + const protein = diet.macros?.protein_g ?? "–"; + const carbs = diet.macros?.carbs_g ?? "–"; + const fats = diet.macros?.fats_g ?? "–"; + const water = diet.hydration_liters ?? "–"; + const calories = Math.round(diet.total_calories || 0); + + const somatotype = safeStr(analysis.somatotype); + const goal = safeStr(workout.focus); + const split = safeStr(workout.split); + + // Lists + const positives = (Array.isArray(analysis.strengths) ? analysis.strengths : []) + .map((x: any) => typeof x === "string" ? x : x?.text).filter(Boolean); // Removed slice limit + + // Map 'weaknesses' to 'improvements' (Prompt returns weaknesses) + const improvements = (Array.isArray(analysis.weaknesses) ? analysis.weaknesses : []) + .map((x: any) => typeof x === "string" ? x : x?.text).filter(Boolean); + + const meals: any[] = Array.isArray(diet.meal_plan_example) ? diet.meal_plan_example : []; + const supplements: any[] = Array.isArray(diet.supplements) ? diet.supplements : []; + const routine: any[] = Array.isArray(workout.routine) ? workout.routine : []; + + // --- HTML Generators --- + + const positivesHtml = positives.length + ? `
    ${positives.map((t: string) => `
  • ${truncateText(t, 500)}
  • `).join("")}
` + : `

${safeStr(analysis.summary, "Sem detalhes.")}

`; + + const improvementsHtml = improvements.length + ? `
    ${improvements.map((t: string) => `
  • ${truncateText(t, 500)}
  • `).join("")}
` + : `

${safeStr(analysis.improvement_summary, "Sem detalhes.")}

`; + + const mealsHtml = meals.map((meal: any, i: number) => { + const options = Array.isArray(meal.options) ? meal.options : []; + const opt1 = options[0] || meal.main_option || ""; + const opt2 = options[1] || ""; + const sub = meal.substitution_suggestion || meal.substitution || ""; + + let html = `
`; + html += `
`; + html += `
${meal.name || `Refeição ${i + 1}`}
`; + if (meal.time_range) html += `
${meal.time_range}
`; + html += `
#${i + 1}
`; + html += `
`; + if (opt1) html += `
Opção 1: ${truncateText(String(opt1), 500)}
`; + if (opt2) html += `
Opção 2: ${truncateText(String(opt2), 500)}
`; + if (sub) html += `
Substituição: ${truncateText(String(sub), 300)}
`; + html += `
`; + return html; + }).join(""); + + const supplementsHtml = supplements.map((sup: any) => { + const name = typeof sup === "string" ? sup : sup.name || "Suplemento"; + const dosage = typeof sup === "string" ? "" : sup.dosage || ""; + const reason = typeof sup === "string" ? "" : sup.reason || ""; // Added reason if available + let html = `
`; + html += `
💊
${truncateText(String(name), 100)}
`; + if (dosage) html += `
${truncateText(String(dosage), 100)}
`; + if (reason) html += `
${truncateText(String(reason), 150)}
`; + html += `
`; + return html; + }).join(""); + + const daysHtml = routine.map((day: any, idx: number) => { + const exs: any[] = Array.isArray(day.exercises) ? day.exercises : []; + const dayName = day.day || day.name || `Dia ${idx + 1}`; + const muscle = day.muscle_group || day.focus || ""; + + const exLines = exs.map((ex: any) => { + if (typeof ex === "string") return `
  • ${ex}
  • `; + const name = ex.name || ex.exercise || ""; + const sets = ex.sets ?? ""; + const reps = ex.reps ?? ""; + const technique = ex.technique || ex.notes || ""; + const sr = [sets ? `${sets}x` : "", reps].filter(Boolean).join(" "); + const left = [name, sr].filter(Boolean).join(" — "); + const full = [left, technique].filter(Boolean).join(" • "); + return `
  • ${truncateText(full, 500) || "-"}
  • `; + }).join(""); + + return `
    +
    +
    +
    ${dayName}
    +
    ${muscle}
    +
    +
    ${workout.split || "Diff"}
    +
    +
      ${exLines}
    +
    `; + }).join(""); + + // --- Template Compacto --- + + return ` + + + + + + + + + + + +
    +
    +
    +
    +
    +
    Protocolo Titan • FoodSnap Coach
    +

    01. Diagnóstico

    +
    +
    +
    + +
    +
    Biótipo
    ${somatotype}
    +
    Objetivo
    ${goal}
    +
    Calorias
    ${calories}
    +
    Split
    ${split}
    +
    + +
    +
    +
    Pontos Fortes
    + ${positivesHtml} +
    +
    +
    Melhorias
    + ${improvementsHtml} +
    +
    + +
    +

    "O sucesso é a soma de pequenos esforços repetidos dia após dia."

    +
    +
    +
    + + +
    +
    +
    +

    02. Dieta

    +
    🥗
    +
    + +
    +
    +
    PROT: ${protein}
    +
    CARB: ${carbs}
    +
    GORD: ${fats}
    +
    💧 ${water}L
    +
    +
    + +
    +
    +
    Refeições
    +
    ${mealsHtml}
    +
    +
    +
    Suplementos
    +
    +
    ${supplementsHtml}
    +
    +
    +
    +
    +
    + + +
    +
    +
    +

    03. Treino

    +
    🏋️
    +
    + +
    + ${daysHtml} +
    + +
    + "${truncateText(quote, 100)}" +
    +
    +
    +
    + +`; +} diff --git a/supabase/functions/whatsapp-webhook/prompt.ts b/supabase/functions/whatsapp-webhook/prompt.ts new file mode 100644 index 0000000..d744277 --- /dev/null +++ b/supabase/functions/whatsapp-webhook/prompt.ts @@ -0,0 +1,257 @@ +export const SYSTEM_PROMPT = ` +Você é um assistente nutricional especializado em análise visual de alimentos. + +Faça apenas estimativas baseadas na imagem e em tabelas nutricionais padrão. +Não dê aconselhamento médico, nem diagnóstico. +Use linguagem objetiva, estilo app fitness. +Seja claro sobre incertezas, sem usar palavras como “aproximadamente”. + +Retorne SOMENTE JSON puro. +NÃO use markdown. +NÃO use \`\`\` . +NÃO escreva qualquer texto fora do JSON. +A resposta DEVE ser um objeto JSON único (nunca um array solto). + +⸻ + +ANALISE a imagem de um alimento ou prato. + +⸻ +REGRAS IMPORTANTES DE IDENTIFICAÇÃO (OBRIGATÓRIAS) + +• Identifique e liste TODOS os alimentos CLARAMENTE VISÍVEIS e EM FOCO na imagem. +• IGNORE completamente: + – Itens desfocados ou fora de foco (bokeh/blur de fundo) + – Reflexos, sombras ou duplicações visuais do mesmo alimento + – Alimentos em segundo plano, mesas vizinhas ou embalagens decorativas + – Qualquer coisa que NÃO esteja no prato/recipiente principal sendo fotografado +• Considere APENAS o prato/recipiente principal que é o foco da foto. +• Nunca retorne apenas um item se mais de um alimento estiver visível. +• Não agrupe alimentos diferentes em um único item. +• Cada alimento identificado deve gerar um objeto separado dentro de items. +• Se algum alimento estiver parcialmente visível ou gerar dúvida, inclua mesmo assim e marque em flags (ex.: "parcial", "porcao_duvidosa"). +• Não repita o mesmo item duas vezes. +• Se houver mais de uma unidade do MESMO alimento e isso estiver claramente visível, use um único item com portion no formato: + “X unidades (Y g)”. +• Se a quantidade NÃO estiver clara, assuma 1 unidade e marque flags com "porcao_duvidosa". + +⸻ +REGRAS CRÍTICAS DE PORÇÃO (MUITO IMPORTANTE) + +ALIMENTOS PREPARADOS, COZIDOS OU MISTURADOS: +(ex.: ovos mexidos, arroz, feijão, carne moída, frango desfiado, massas, purês, refogados, preparações caseiras) + +• NUNCA use número de unidades. +• NUNCA use termos como: + “2 ovos”, “1 filé”, “3 colheres”, “200 g”, “1 pedaço”. +• NUNCA tente converter visualmente em quantidade de ingredientes crus. + +• Para esses alimentos, o campo portion DEVE: + – descrever o preparo + – usar apenas referência visual + +Exemplos CORRETOS: +• “Ovos mexidos – porção média no prato” +• “Arroz branco cozido – porção média” +• “Feijão carioca – porção pequena” +• “Carne moída refogada – porção média” +• “Macarrão cozido – porção grande” + +Exemplos PROIBIDOS: +• “2 ovos mexidos” +• “1 concha de feijão”\n• “3 colheres de arroz” +• “150 g de macarrão” + +SE ESTA REGRA FOR VIOLADA, CONSIDERE A RESPOSTA INVÁLIDA E REFAÇA INTERNAMENTE ANTES DE RESPONDER. + +⸻ +ALIMENTOS INTEIROS E SEPARÁVEIS (ÚNICO CASO EM QUE UNIDADES SÃO PERMITIDAS) + +Use unidades APENAS quando o alimento estiver: +• inteiro +• claramente separável +• não misturado + +Exemplos permitidos: +• frutas inteiras (banana, maçã, laranja) +• ovos cozidos inteiros +• pães inteiros +• itens embalados individuais visíveis + +Para frutas inteiras, use limites conservadores: +• Banana: 1 a 2 unidades (a menos que a imagem mostre claramente mais) +• Maçã / Laranja: 1 unidade cada (a menos que apareçam múltiplas claramente) + +⸻ +REGRAS DE CÁLCULO + +• O objeto total DEVE ser a soma exata de todos os itens listados: + – calories + – protein + – carbs + – fat + – fiber + – sugar\n – sodium_mg + +• Use valores coerentes com bases nutricionais reais. +• category deve refletir o tipo do prato (ex.: “Almoço”, “Jantar”, “Café da manhã”, “Lanche”, “Refeição caseira”). + +⸻ +QUALIDADE E CONSISTÊNCIA + +• Se houver mais de um alimento identificado e apenas um item for retornado, considere a resposta inválida e refaça internamente. +• confidence deve refletir a clareza da imagem. +• assumptions deve listar de 1 a 3 suposições feitas (tamanho visual, preparo, quantidade). +• insights: no máximo 3 frases curtas, sem moralismo. + +⸻ +CASO NÃO SEJA COMIDA + +• Se a imagem não contiver alimento: + – retorne items vazio + – explique o motivo em confidence + – tip.title e tip.text devem orientar o usuário a enviar uma foto de alimento + +⸻ +FORMATO DE RESPOSTA (OBRIGATÓRIO) + +{ + "items":[ + { + "name":"", + "portion":"", + "calories":0, + "protein":0, + "carbs":0, + "fat":0, + "fiber":0, + "sugar":0, + "sodium_mg":0, + "flags":[] + } + ], + "total":{ + "calories":0, + "protein":0, + "carbs":0, + "fat":0, + "fiber":0, + "sugar":0, + "sodium_mg":0 + }, + "category":"", + "health_score":0, + "confidence":"", + "assumptions":[], + "questions":[], + "insights":[], + "tip":{ + "title":"", + "text":"", + "reason":"" + }, + "swap_suggestions":[], + "next_best_actions":[] +} +`; + +export const COACH_SYSTEM_PROMPT = ` +Você é o "Titan Coach", um treinador olímpico de elite e nutricionista esportivo PhD. +Sua missão é analisar o físico de um usuário através de 3 fotos (Frente, Lado, Costas) e criar um **Protocolo de Transformação** completo, rico e detalhado. + +RETORNE APENAS JSON. +NÃO use Markdown. +Formato de Resposta (Siga estritamente esta estrutura): + +{ + "analysis": { + "body_fat_percentage": 0, + "somatotype": "Ectomorfo" | "Mesomorfo" | "Endomorfo", + "muscle_mass_level": "Baixo" | "Médio" | "Alto", + "posture_analysis": "Texto detalhado sobre postura (ex: leve cifose, lordose, desvios laterais)", + "strengths": ["Ombros largos", "Cintura fina", "Bons quadríceps"], + "weaknesses": ["Panturrilhas pouco desenvolvidas", "Peitoral superior fraco"] + }, + "diet": { + "total_calories": 0, + "macros": { + "protein_g": 0, + "carbs_g": 0, + "fats_g": 0 + }, + "hydration_liters": 0, + "supplements": [ + { "name": "Creatina", "dosage": "5g pós-treino", "reason": "Aumento de força e recuperação" }, + { "name": "Whey Protein", "dosage": "30g se não bater a meta", "reason": "Praticidade para bater proteínas" }, + { "name": "Multivitamínico", "dosage": "1 caps almoço", "reason": "Micro-nutrientes essenciais" } + ], + "meal_plan_example": [ + { + "name": "Café da Manhã", + "time_range": "07:00 - 08:00", + "options": [ + "Opção 1: 3 Ovos mexidos + 1 Banana + 40g Aveia", + "Opção 2: 2 Fatias Pão Integral + 100g Frango Desfiado + Queijo Cottage" + ], + "substitution_suggestion": "Para vegetarianos: Trocar frango por Tofu ou ovos por Shake proteico vegano." + }, + { + "name": "Almoço", + "time_range": "12:00 - 13:00", + "options": [ + "Opção 1: 150g Frango Grelhado + 120g Arroz Branco + Vegetais Verdes à vontade", + "Opção 2: 150g Patinho Moído + 150g Batata Inglesa + Salada Mista" + ], + "substitution_suggestion": "Se enjoar de arroz, use Macarrão Integral (mesmo peso) ou Batata Doce (peso x1.3)." + }, + { + "name": "Lanche da Tarde", + "time_range": "16:00 - 16:30", + "options": [ + "Opção 1: 1 Iogurte Grego Zero + 20g Nozes", + "Opção 2: 1 Fruta + 1 Dose de Whey" + ], + "substitution_suggestion": "Pode trocar as gorduras (nozes) por Pasta de Amendoim." + }, + { + "name": "Jantar", + "time_range": "20:00 - 21:00", + "options": [ + "Opção 1: 150g Peixe Branco (Tilápia) + Salada Completa + Azeite de Oliva", + "Opção 2: Omelete de 3 Ovos com Espinafre e Tomate" + ], + "substitution_suggestion": "Evite carboidratos pesados a noite se o objetivo for secar." + } + ] + }, + "workout": { + "split": "ABC" | "ABCD" | "ABCDE" | "Fullbody", + "focus": "Hipertrofia" | "Força" | "Perda de Gordura", + "frequency_days": 0, + "injury_adaptations": { + "knee_pain": "Substituir Agachamento por Leg Press 45 com pés altos", + "shoulder_pain": "Fazer Supino com Halteres pegada neutra ao invés de barra", + "back_pain": "Evitar Terra e Remada Curvada, preferir máquinas apoiadas" + }, + "routine": [ + { + "day": "Segunda", + "muscle_group": "Peito + Tríceps", + "exercises": [ + { "name": "Supino Inclinado com Halteres", "sets": 4, "reps": "8-12", "technique": "Focar na parte superior, descida controlada" }, + { "name": "Crucifixo Máquina", "sets": 3, "reps": "12-15", "technique": "Pico de contração de 1s" } + ] + } + ] + }, + "motivation_quote": "Uma frase curta de impacto." +} + +Regras IMPORTANTES: +1. Seja MUITO DETALHADO na dieta. Dê SEMPRE pelo menos 2 opções para CADA refeição ("options"). +2. Inclua o horário sugerido ("time_range") para cada refeição. +3. O campo "substitution_suggestion" deve dar uma alternativa clara de troca de alimentos (ex: trocar carbo X por Y). +4. Adapte o treino ao biotipo (ex: Ectomorfo menos volume, Endomorfo mais cardio). +5. Nos suplementos, especifique COMO tomar e PORQUE. +6. A resposta DEVE ser um JSON válido. +`; diff --git a/supabase/migrations/20240105000001_whatsapp_state.sql b/supabase/migrations/20240105000001_whatsapp_state.sql new file mode 100644 index 0000000..5e00d98 --- /dev/null +++ b/supabase/migrations/20240105000001_whatsapp_state.sql @@ -0,0 +1,31 @@ +-- Create table to store conversation state +create table if not exists public.whatsapp_conversations ( + phone_number text primary key, + state text not null default 'IDLE', -- IDLE, COACH_FRONT, COACH_SIDE, COACH_BACK, COACH_GOAL + temp_data jsonb default '{}'::jsonb, + updated_at timestamp with time zone default now() +); + +-- Turn on RLS +alter table public.whatsapp_conversations enable row level security; + +-- Allow service role full access +create policy "Service role full access" + on public.whatsapp_conversations + for all + to service_role + using (true) + with check (true); + +-- Create a bucket for temporary coach uploads if it doesn't exist +insert into storage.buckets (id, name, public) +values ('coach-uploads', 'coach-uploads', true) +on conflict (id) do nothing; + +create policy "Public Access" + on storage.objects for select + using ( bucket_id = 'coach-uploads' ); + +create policy "Service Role Upload" + on storage.objects for insert + with check ( bucket_id = 'coach-uploads' ); diff --git a/supabase/migrations/20260120_create_coach_analyses.sql b/supabase/migrations/20260120_create_coach_analyses.sql new file mode 100644 index 0000000..942ec85 --- /dev/null +++ b/supabase/migrations/20260120_create_coach_analyses.sql @@ -0,0 +1,39 @@ +-- Create table for Coach AI analyses +create table if not exists public.coach_analyses ( + id uuid default gen_random_uuid() primary key, + user_id uuid references auth.users(id) on delete set null, + created_at timestamptz default now(), + + -- Metadata + source text default 'whatsapp', -- 'web', 'whatsapp' + image_url text, + + -- AI Data + ai_raw_response text, + ai_structured jsonb, -- Full JSON response + + -- Structured Fields for Analytics + biotype text, -- 'Ectomorph', 'Mesomorph', 'Endomorph' + estimated_body_fat numeric, + muscle_mass_level text, -- 'Low', 'Medium', 'High' + goal_suggestion text, -- 'Cut', 'Bulk', 'Recomp' + + -- Plan Usage + used_free_quota boolean default false +); + +-- Enable RLS +alter table public.coach_analyses enable row level security; + +-- Policies +create policy "Users can view their own coach analyses" + on public.coach_analyses for select + using (auth.uid() = user_id); + +create policy "Service role insert coach analyses" + on public.coach_analyses for insert + with check (true); + +create policy "Service role updates" + on public.coach_analyses for update + using (true); diff --git a/supabase/migrations/20260120_professional_schema.sql b/supabase/migrations/20260120_professional_schema.sql new file mode 100644 index 0000000..fe5e8d0 --- /dev/null +++ b/supabase/migrations/20260120_professional_schema.sql @@ -0,0 +1,190 @@ +-- ============================================= +-- MIGRATION: PROFESSIONAL SAAS MODULE +-- Description: Creates tables for Professionals, Students, Assessments, and Workouts. +-- ============================================= + +-- 1. PROFESSIONALS TABLE (Extends Profile for Pro Users) +CREATE TABLE IF NOT EXISTS public.professionals ( + id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY, + business_name TEXT, + cref_crn TEXT, -- License number + bio TEXT, + specialties TEXT[], + logo_url TEXT, + primary_color TEXT DEFAULT '#059669', -- Brand Color + + contacts JSONB DEFAULT '{}'::jsonb, -- { "whatsapp": "...", "instagram": "..." } + + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Enable RLS +ALTER TABLE public.professionals ENABLE ROW LEVEL SECURITY; + +-- Policies for Professionals +CREATE POLICY "Professionals can view/edit own profile" +ON public.professionals +FOR ALL +USING (auth.uid() = id) +WITH CHECK (auth.uid() = id); + +CREATE POLICY "Public can view professionals (optional, for directory)" +ON public.professionals +FOR SELECT +USING (true); + + +-- 2. PRO_STUDENTS TABLE (The Professional's CRM) +CREATE TABLE IF NOT EXISTS public.pro_students ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + professional_id UUID REFERENCES public.professionals(id) ON DELETE CASCADE NOT NULL, + + name TEXT NOT NULL, + email TEXT, + phone TEXT, + status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'pending')), + + -- Optional: Link to a real app user if they convert + linked_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + + goals TEXT, + notes TEXT, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Enable RLS +ALTER TABLE public.pro_students ENABLE ROW LEVEL SECURITY; + +-- Policies for Pro Students +CREATE POLICY "Professionals can manage own students" +ON public.pro_students +FOR ALL +USING (auth.uid() = professional_id) +WITH CHECK (auth.uid() = professional_id); + +CREATE POLICY "Students can view their own record" +ON public.pro_students +FOR SELECT +USING (auth.uid() = linked_user_id); + + +-- 3. PRO_ASSESSMENTS TABLE (Physical Evaluations) +CREATE TABLE IF NOT EXISTS public.pro_assessments ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + professional_id UUID REFERENCES public.professionals(id) ON DELETE CASCADE NOT NULL, + student_id UUID REFERENCES public.pro_students(id) ON DELETE CASCADE NOT NULL, + + date DATE DEFAULT CURRENT_DATE, + + -- Basic Metrics + weight DECIMAL(5,2), -- kg + height DECIMAL(3,2), -- meters + age INTEGER, + + -- Calculated + bf_percent DECIMAL(4,1), -- Body Fat % + muscle_percent DECIMAL(4,1), + bmi DECIMAL(4,1), + + -- JSON Data for flexibility (skinfolds, circumferences, photos) + -- Structure: { "chest": 90, "waist": 80, ... } + measurements JSONB DEFAULT '{}'::jsonb, + + -- Structure: { "method": "pollock7", "folds": { ... } } + methodology JSONB DEFAULT '{}'::jsonb, + + -- Structure: ["url1", "url2"] + photos TEXT[], + + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Enable RLS +ALTER TABLE public.pro_assessments ENABLE ROW LEVEL SECURITY; + +-- Policies for Assessments +CREATE POLICY "Professionals can manage assessments" +ON public.pro_assessments +FOR ALL +USING (auth.uid() = professional_id) +WITH CHECK (auth.uid() = professional_id); + +CREATE POLICY "Students can view their own assessments" +ON public.pro_assessments +FOR SELECT +USING ( + EXISTS ( + SELECT 1 FROM public.pro_students + WHERE id = pro_assessments.student_id + AND linked_user_id = auth.uid() + ) +); + + +-- 4. PRO_WORKOUTS TABLE (Workout Library) +CREATE TABLE IF NOT EXISTS public.pro_workouts ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + professional_id UUID REFERENCES public.professionals(id) ON DELETE CASCADE NOT NULL, + + title TEXT NOT NULL, + description TEXT, + difficulty TEXT CHECK (difficulty IN ('beginner', 'intermediate', 'advanced')), + + -- Structure: [{ "name": "Supino", "sets": 3, "reps": "10-12", "video": "..." }] + exercises JSONB DEFAULT '[]'::jsonb, + + tags TEXT[], -- ['hipertrofia', 'emagrecimento'] + + is_template BOOLEAN DEFAULT false, -- If true, it's a library item. If false, assigned to a specific student? + -- Actually, let's keep it simple: Workouts are templates or assigned. + + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Enable RLS +ALTER TABLE public.pro_workouts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Professionals can manage own workouts" +ON public.pro_workouts +FOR ALL +USING (auth.uid() = professional_id) +WITH CHECK (auth.uid() = professional_id); + + +-- 5. PRO_ASSIGNMENTS (Assigning Workouts to Students) +CREATE TABLE IF NOT EXISTS public.pro_assignments ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + professional_id UUID REFERENCES public.professionals(id) ON DELETE CASCADE NOT NULL, + student_id UUID REFERENCES public.pro_students(id) ON DELETE CASCADE NOT NULL, + workout_id UUID REFERENCES public.pro_workouts(id) ON DELETE CASCADE NOT NULL, + + start_date DATE DEFAULT CURRENT_DATE, + end_date DATE, + + notes TEXT, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Enable RLS +ALTER TABLE public.pro_assignments ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Professionals can manage assignments" +ON public.pro_assignments +FOR ALL +USING (auth.uid() = professional_id) +WITH CHECK (auth.uid() = professional_id); + +CREATE POLICY "Students can view their assignments" +ON public.pro_assignments +FOR SELECT +USING ( + EXISTS ( + SELECT 1 FROM public.pro_students + WHERE id = pro_assignments.student_id + AND linked_user_id = auth.uid() + ) +); diff --git a/supabase/migrations/20260217_create_payments.sql b/supabase/migrations/20260217_create_payments.sql new file mode 100644 index 0000000..0c82fc9 --- /dev/null +++ b/supabase/migrations/20260217_create_payments.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS payments ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID REFERENCES auth.users(id) NOT NULL, + amount DECIMAL(10, 2) NOT NULL, + status TEXT NOT NULL DEFAULT 'completed', -- completed, pending, failed + plan_type TEXT NOT NULL, -- monthly, yearly, lifetime + payment_method TEXT, -- credit_card, pix, etc + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +ALTER TABLE payments ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own payments" +ON payments FOR SELECT +USING (auth.uid() = user_id); diff --git a/supabase_introspection.sql b/supabase_introspection.sql new file mode 100644 index 0000000..f3d0e83 --- /dev/null +++ b/supabase_introspection.sql @@ -0,0 +1,208 @@ +-- ============================================================ +-- FOODSNAP - SUPABASE FULL INTROSPECTION (SINGLE JSON OUTPUT) +-- Execute no SQL Editor do Supabase (Dashboard > SQL Editor) +-- Retorna TUDO em um único JSON +-- ============================================================ + +SELECT jsonb_build_object( + + -- 1. TABLES & COLUMNS + 'tables', ( + SELECT jsonb_agg(jsonb_build_object( + 'table', t.table_name, + 'column', c.column_name, + 'type', c.data_type, + 'udt', c.udt_name, + 'default', c.column_default, + 'nullable', c.is_nullable + ) ORDER BY t.table_name, c.ordinal_position) + FROM information_schema.tables t + JOIN information_schema.columns c + ON t.table_name = c.table_name AND t.table_schema = c.table_schema + WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' + ), + + -- 2. VIEWS + 'views', ( + SELECT jsonb_agg(jsonb_build_object( + 'name', table_name, + 'definition', view_definition + )) + FROM information_schema.views + WHERE table_schema = 'public' + ), + + -- 3. FOREIGN KEYS + 'foreign_keys', ( + SELECT jsonb_agg(jsonb_build_object( + 'table', tc.table_name, + 'column', kcu.column_name, + 'ref_table', ccu.table_name, + 'ref_column', ccu.column_name, + 'constraint', tc.constraint_name + )) + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public' + ), + + -- 4. PRIMARY KEYS & UNIQUE + 'primary_keys', ( + SELECT jsonb_agg(jsonb_build_object( + 'table', tc.table_name, + 'constraint', tc.constraint_name, + 'type', tc.constraint_type, + 'column', kcu.column_name + )) + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + WHERE tc.table_schema = 'public' AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE') + ), + + -- 5. CHECK CONSTRAINTS + 'check_constraints', ( + SELECT jsonb_agg(jsonb_build_object( + 'table', tc.table_name, + 'constraint', tc.constraint_name, + 'check', cc.check_clause + )) + FROM information_schema.table_constraints tc + JOIN information_schema.check_constraints cc + ON tc.constraint_name = cc.constraint_name AND tc.constraint_schema = cc.constraint_schema + WHERE tc.table_schema = 'public' AND tc.constraint_type = 'CHECK' + ), + + -- 6. INDEXES + 'indexes', ( + SELECT jsonb_agg(jsonb_build_object( + 'table', tablename, + 'index', indexname, + 'def', indexdef + )) + FROM pg_indexes + WHERE schemaname = 'public' + ), + + -- 7. RLS POLICIES + 'rls_policies', ( + SELECT jsonb_agg(jsonb_build_object( + 'table', tablename, + 'policy', policyname, + 'permissive', permissive, + 'roles', roles, + 'cmd', cmd, + 'qual', qual, + 'with_check', with_check + )) + FROM pg_policies + WHERE schemaname = 'public' + ), + + -- 8. FUNCTIONS + 'functions', ( + SELECT jsonb_agg(jsonb_build_object( + 'name', p.proname, + 'args', pg_get_function_arguments(p.oid), + 'returns', pg_get_function_result(p.oid), + 'definition', pg_get_functiondef(p.oid) + )) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + ), + + -- 9. TRIGGERS + 'triggers', ( + SELECT jsonb_agg(jsonb_build_object( + 'name', trigger_name, + 'event', event_manipulation, + 'table', event_object_table, + 'action', action_statement, + 'timing', action_timing + )) + FROM information_schema.triggers + WHERE trigger_schema = 'public' + ), + + -- 10. STORAGE BUCKETS + 'storage_buckets', ( + SELECT jsonb_agg(jsonb_build_object( + 'id', id, + 'name', name, + 'public', public, + 'size_limit', file_size_limit, + 'mime_types', allowed_mime_types + )) + FROM storage.buckets + ), + + -- 11. STORAGE POLICIES + 'storage_policies', ( + SELECT jsonb_agg(jsonb_build_object( + 'policy', policyname, + 'table', tablename, + 'cmd', cmd, + 'qual', qual, + 'with_check', with_check + )) + FROM pg_policies + WHERE schemaname = 'storage' + ), + + -- 12. AUTH STATS + 'auth_stats', ( + SELECT jsonb_build_object( + 'total_users', count(*), + 'confirmed', count(*) FILTER (WHERE email_confirmed_at IS NOT NULL), + 'active_30d', count(*) FILTER (WHERE last_sign_in_at > now() - interval '30 days') + ) + FROM auth.users + ), + + -- 13. ROW COUNTS + 'row_counts', ( + SELECT jsonb_agg(jsonb_build_object( + 'table', relname, + 'rows', n_live_tup + ) ORDER BY n_live_tup DESC) + FROM pg_stat_user_tables + WHERE schemaname = 'public' + ), + + -- 14. EXTENSIONS + 'extensions', ( + SELECT jsonb_agg(jsonb_build_object( + 'name', extname, + 'version', extversion + )) + FROM pg_extension + ), + + -- 15. REALTIME + 'realtime_tables', ( + SELECT jsonb_agg(jsonb_build_object( + 'pub', pubname, + 'schema', schemaname, + 'table', tablename + )) + FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + ), + + -- 16. ENUMS + 'enums', ( + SELECT jsonb_agg(jsonb_build_object( + 'type', t.typname, + 'value', e.enumlabel + )) + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE n.nspname = 'public' + ) + +) AS full_introspection; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d8a27ae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f1abd1f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + return { + server: { + port: 3000, + host: '0.0.0.0', + }, + plugins: [react()], + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + } + } + }; +});