diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 194f920..a852574 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -8,6 +8,12 @@ CLERK_WEBHOOK_SECRET=whsec_your_webhook_secret_here # Get your API key from https://platform.deepseek.com/ DEEPSEEK_API_KEY=sk-your_deepseek_api_key_here +# Email Service (Resend) +# Get your API key from https://resend.com/api-keys +RESEND_API_KEY=re_your_resend_api_key_here +EMAIL_FROM=FitAI +EMAIL_REPLY_TO=support@yourdomain.com + # Database (optional - defaults to ./fitai.db) DATABASE_PATH=./fitai.db diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index ff6e7e9..81e63f7 100644 Binary files a/apps/admin/data/fitai.db and b/apps/admin/data/fitai.db differ diff --git a/apps/admin/package-lock.json b/apps/admin/package-lock.json index 2b7cfe1..a564f04 100644 --- a/apps/admin/package-lock.json +++ b/apps/admin/package-lock.json @@ -14,6 +14,8 @@ "@hookform/resolvers": "^5.2.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "@react-email/components": "^1.0.8", + "@react-email/render": "^2.0.4", "@tanstack/react-query": "^5.90.7", "@types/bcryptjs": "^3.0.0", "@types/sqlite3": "^5.1.0", @@ -39,6 +41,8 @@ "react-dom": "^19.2.0", "react-hook-form": "^7.66.0", "recharts": "^3.3.0", + "resend": "^6.9.3", + "sonner": "^2.0.7", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "svix": "^1.81.0", @@ -2780,6 +2784,355 @@ } } }, + "node_modules/@react-email/body": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.1.tgz", + "integrity": "sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/button": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.1.tgz", + "integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/code-block": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.1.tgz", + "integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/code-inline": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.6.tgz", + "integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/column": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.14.tgz", + "integrity": "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/components": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-1.0.8.tgz", + "integrity": "sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==", + "license": "MIT", + "dependencies": { + "@react-email/body": "0.2.1", + "@react-email/button": "0.2.1", + "@react-email/code-block": "0.2.1", + "@react-email/code-inline": "0.0.6", + "@react-email/column": "0.0.14", + "@react-email/container": "0.0.16", + "@react-email/font": "0.0.10", + "@react-email/head": "0.0.13", + "@react-email/heading": "0.0.16", + "@react-email/hr": "0.0.12", + "@react-email/html": "0.0.12", + "@react-email/img": "0.0.12", + "@react-email/link": "0.0.13", + "@react-email/markdown": "0.0.18", + "@react-email/preview": "0.0.14", + "@react-email/render": "2.0.4", + "@react-email/row": "0.0.13", + "@react-email/section": "0.0.17", + "@react-email/tailwind": "2.0.5", + "@react-email/text": "0.1.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/container": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz", + "integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/font": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.10.tgz", + "integrity": "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/head": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.13.tgz", + "integrity": "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/heading": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.16.tgz", + "integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/hr": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.12.tgz", + "integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/html": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.12.tgz", + "integrity": "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/img": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.12.tgz", + "integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/link": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.13.tgz", + "integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/markdown": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.18.tgz", + "integrity": "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==", + "license": "MIT", + "dependencies": { + "marked": "^15.0.12" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/preview": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.14.tgz", + "integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/render": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz", + "integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/row": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz", + "integrity": "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/section": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.17.tgz", + "integrity": "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/tailwind": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-2.0.5.tgz", + "integrity": "sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==", + "license": "MIT", + "dependencies": { + "tailwindcss": "^4.1.18" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-email/body": "0.2.1", + "@react-email/button": "0.2.1", + "@react-email/code-block": "0.2.1", + "@react-email/code-inline": "0.0.6", + "@react-email/container": "0.0.16", + "@react-email/heading": "0.0.16", + "@react-email/hr": "0.0.12", + "@react-email/img": "0.0.12", + "@react-email/link": "0.0.13", + "@react-email/preview": "0.0.14", + "@react-email/text": "0.1.6", + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@react-email/body": { + "optional": true + }, + "@react-email/button": { + "optional": true + }, + "@react-email/code-block": { + "optional": true + }, + "@react-email/code-inline": { + "optional": true + }, + "@react-email/container": { + "optional": true + }, + "@react-email/heading": { + "optional": true + }, + "@react-email/hr": { + "optional": true + }, + "@react-email/img": { + "optional": true + }, + "@react-email/link": { + "optional": true + }, + "@react-email/preview": { + "optional": true + } + } + }, + "node_modules/@react-email/tailwind/node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/@react-email/text": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", + "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz", @@ -2813,6 +3166,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -5550,7 +5916,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5674,6 +6039,73 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -7459,6 +7891,53 @@ "dev": true, "license": "MIT" }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -9463,6 +9942,15 @@ "node": ">=0.10" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -9680,6 +10168,18 @@ "tmpl": "1.0.5" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10663,6 +11163,19 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10721,6 +11234,15 @@ "dev": true, "license": "ISC" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10909,6 +11431,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -11101,6 +11629,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -11136,6 +11679,15 @@ "dev": true, "license": "MIT" }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -11587,6 +12139,27 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resend": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz", + "integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11864,6 +12437,18 @@ ], "license": "BSD-3-Clause" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -12209,6 +12794,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -12739,13 +13334,12 @@ } }, "node_modules/svix": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/svix/-/svix-1.81.0.tgz", - "integrity": "sha512-Q4DiYb1ydhRYqez65vZES8AkGY2oxn26qP7mLVbMf8Orrveb54TZLkaVG5zr7eJT4T3zYRThkKf6aOnvzgwhYw==", + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", "license": "MIT", "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0", + "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, diff --git a/apps/admin/package.json b/apps/admin/package.json index ebac61e..43205f8 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -20,6 +20,8 @@ "@hookform/resolvers": "^5.2.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "@react-email/components": "^1.0.8", + "@react-email/render": "^2.0.4", "@tanstack/react-query": "^5.90.7", "@types/bcryptjs": "^3.0.0", "@types/sqlite3": "^5.1.0", @@ -45,6 +47,8 @@ "react-dom": "^19.2.0", "react-hook-form": "^7.66.0", "recharts": "^3.3.0", + "resend": "^6.9.3", + "sonner": "^2.0.7", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "svix": "^1.81.0", diff --git a/apps/admin/src/app/api/fitness-profile/route.ts b/apps/admin/src/app/api/fitness-profile/route.ts index 44ecf48..8fec5c0 100644 --- a/apps/admin/src/app/api/fitness-profile/route.ts +++ b/apps/admin/src/app/api/fitness-profile/route.ts @@ -21,7 +21,7 @@ export async function GET(request: NextRequest) { } const profile = db - .prepare(`SELECT * FROM fitness_profiles WHERE userId = ?`) + .prepare(`SELECT * FROM fitness_profiles WHERE user_id = ?`) .get(userId); if (profile) { @@ -78,8 +78,8 @@ export async function POST(request: NextRequest) { // Check if profile exists const existingProfile = db - .prepare(`SELECT userId FROM fitness_profiles WHERE userId = ?`) - .get(userId) as { userId: string } | undefined; + .prepare(`SELECT user_id FROM fitness_profiles WHERE user_id = ?`) + .get(userId) as { user_id: string } | undefined; const now = new Date().toISOString(); const fitnessGoalsJson = JSON.stringify(fitnessGoals || []); @@ -92,14 +92,13 @@ export async function POST(request: NextRequest) { weight = ?, age = ?, gender = ?, - fitnessGoals = ?, - activityLevel = ?, - medicalConditions = ?, + fitness_goals = ?, + activity_level = ?, + medical_conditions = ?, allergies = ?, injuries = ?, - preferences = ?, - updatedAt = ? - WHERE userId = ?`, + updated_at = ? + WHERE user_id = ?`, ).run( height, weight, @@ -110,7 +109,6 @@ export async function POST(request: NextRequest) { medicalConditions || null, allergies || null, injuries || null, - preferences || null, now, userId, ); @@ -121,12 +119,15 @@ export async function POST(request: NextRequest) { }); } else { // Create new profile + const profileId = `fp_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + db.prepare( `INSERT INTO fitness_profiles - (userId, height, weight, age, gender, fitnessGoals, activityLevel, - medicalConditions, allergies, injuries, preferences, createdAt, updatedAt) + (id, user_id, height, weight, age, gender, fitness_goals, activity_level, + medical_conditions, allergies, injuries, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( + profileId, userId, height, weight, @@ -137,7 +138,6 @@ export async function POST(request: NextRequest) { medicalConditions || null, allergies || null, injuries || null, - preferences || null, now, now, ); diff --git a/apps/admin/src/app/api/users/create/route.ts b/apps/admin/src/app/api/users/create/route.ts new file mode 100644 index 0000000..b1d9a5e --- /dev/null +++ b/apps/admin/src/app/api/users/create/route.ts @@ -0,0 +1,159 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth, clerkClient } from "@clerk/nextjs/server"; +import { db, users, clients } from "@fitai/database"; +import { createUserSchema } from "@/lib/validation/user-schemas"; +import { sendWelcomeEmail, sendInvitationEmail } from "@/lib/email"; +import { + successResponse, + errorResponse, + unauthorizedResponse, +} from "@/lib/api/responses"; +import log from "@/lib/logger"; + +/** + * POST /api/users/create + * + * Create a new user with support for: + * - Direct creation (database only, no Clerk account) + * - Invitation-based creation (sends Clerk invitation email) + * - Client-specific fields (membership type, status) + * - Welcome emails for clients + */ +export async function POST(request: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) { + return unauthorizedResponse("Unauthorized"); + } + + const body = await request.json(); + const validationResult = createUserSchema.safeParse(body); + + if (!validationResult.success) { + return errorResponse("Validation failed", { + status: 400, + details: validationResult.error.flatten().fieldErrors as Record< + string, + string[] + >, + }); + } + + const data = validationResult.data; + + // Note: Email is required in current schema + // TODO: Add schema migration to make email optional for direct creation + if (!data.email) { + return errorResponse("Email is required", { status: 400 }); + } + + // Case 1: Send Clerk Invitation (requires email) + if (data.sendInvitation && data.email) { + try { + const client = await clerkClient(); + const invitation = await client.invitations.createInvitation({ + emailAddress: data.email, + publicMetadata: { + role: data.role, + gymId: data.gymId, + }, + }); + + log.info("Clerk invitation sent", { + email: data.email, + role: data.role, + invitationId: invitation.id, + }); + + // Send custom invitation email (in addition to Clerk's) + if (invitation.url) { + await sendInvitationEmail(data.email, data.role, invitation.url); + } + + return successResponse( + { + invitation: { + id: invitation.id, + email: invitation.emailAddress, + status: invitation.status, + }, + }, + { status: 200 }, + ); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + log.error("Failed to send Clerk invitation", error); + return errorResponse(`Failed to send invitation: ${errorMessage}`, { + status: 500, + }); + } + } + + // Case 2: Direct Creation (database only, no Clerk account) + // This creates a user record that can later be linked when they sign up + + // Generate a temporary user ID (will be replaced when they sign up with Clerk) + const tempUserId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Insert user into database + const [newUser] = await db + .insert(users) + .values({ + id: tempUserId, + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + role: data.role, + phone: data.phone, + gymId: data.gymId, + }) + .returning(); + + log.info("User created in database", { + userId: newUser.id, + role: data.role, + hasEmail: !!data.email, + }); + + // Case 3: Create client record if role is client + if (data.role === "client" && data.membershipType) { + const clientId = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + await db.insert(clients).values({ + id: clientId, + userId: newUser.id, + membershipType: data.membershipType, + membershipStatus: data.membershipStatus || "active", + joinDate: new Date(), + }); + + log.info("Client record created", { + userId: newUser.id, + membershipType: data.membershipType, + }); + + // Send welcome email if requested and email provided + if (data.sendWelcomeEmail && data.email) { + await sendWelcomeEmail(data.email, data.firstName); + log.info("Welcome email sent", { email: data.email }); + } + } + + return successResponse( + { + user: { + id: newUser.id, + email: newUser.email, + firstName: newUser.firstName, + lastName: newUser.lastName, + role: newUser.role, + }, + }, + { status: 201 }, + ); + } catch (error) { + log.error("Failed to create user", error); + return errorResponse("Failed to create user", { status: 500 }); + } +} diff --git a/apps/admin/src/app/api/webhooks/route.ts b/apps/admin/src/app/api/webhooks/route.ts index bed43a9..77e33e0 100644 --- a/apps/admin/src/app/api/webhooks/route.ts +++ b/apps/admin/src/app/api/webhooks/route.ts @@ -128,6 +128,30 @@ export async function POST(req: Request) { now, ); + // Auto-create client record for self-registered users (role is "client") + if (role === "client") { + const clientId = `cli_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + const clientStmt = db.prepare(` + INSERT INTO clients (id, user_id, membership_type, membership_status, join_date, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + clientStmt.run( + clientId, + id, + "basic", // Default membership type + "active", // Default membership status + now, + now, + now, + ); + + log.info("Client record auto-created for self-registered user", { + userId: id, + clientId, + }); + } + // If this is a client invited by a trainer, create trainer-client link if (roleAssigned === "client" && inviterUserId && gymId) { const inviterRow = db diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 21ff7f7..2ae524c 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -9,6 +9,7 @@ import { UserButton, } from "@clerk/nextjs"; import { Sidebar } from "@/components/ui/Sidebar"; +import { Toaster } from "sonner"; const inter = Inter({ subsets: ["latin"] }); @@ -28,10 +29,9 @@ export default function RootLayout({
-
- {children} -
+
{children}
+ diff --git a/apps/admin/src/app/recommendations/page.tsx b/apps/admin/src/app/recommendations/page.tsx index f9ab307..f78b8ff 100644 --- a/apps/admin/src/app/recommendations/page.tsx +++ b/apps/admin/src/app/recommendations/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useUser } from "@clerk/nextjs"; import log from "@/lib/logger"; +import { toast } from "@/lib/toast"; interface User { id: string; @@ -68,14 +69,14 @@ export default function RecommendationsPage() { if (!res.ok) { const error = await res.json(); - alert(`Error: ${error.error}`); + toast.error(`Error: ${error.error}`); } else { - alert("Recommendation generated successfully!"); + toast.success("Recommendation generated successfully!"); fetchData(); // Refresh data } } catch (error) { log.error("Failed to generate recommendation", error); - alert("Failed to generate recommendation."); + toast.error("Failed to generate recommendation."); } finally { setGenerating(null); } @@ -98,13 +99,16 @@ export default function RecommendationsPage() { if (!res.ok) { const errorData = await res.json(); - alert(`Failed to update status: ${errorData.error || "Unknown error"}`); + toast.error( + `Failed to update status: ${errorData.error || "Unknown error"}`, + ); } else { + toast.success("Recommendation status updated"); fetchData(); // Refresh data } } catch (error) { log.error("Failed to approve recommendation", error); - alert("Error processing request"); + toast.error("Error processing request"); } }; @@ -136,16 +140,16 @@ export default function RecommendationsPage() { if (!res.ok) { const errorData = await res.json(); - alert( + toast.error( `Failed to update recommendation: ${errorData.error || "Unknown error"}`, ); } else { - alert("Recommendation updated successfully!"); + toast.success("Recommendation updated successfully!"); fetchData(); // Refresh data } } catch (error) { log.error("Failed to update recommendation", error); - alert("Failed to update recommendation."); + toast.error("Failed to update recommendation."); } }; diff --git a/apps/admin/src/components/recommendations/generate-button.tsx b/apps/admin/src/components/recommendations/generate-button.tsx index 52767fe..e8d944f 100644 --- a/apps/admin/src/components/recommendations/generate-button.tsx +++ b/apps/admin/src/components/recommendations/generate-button.tsx @@ -2,43 +2,46 @@ import { useState } from "react"; import { Loader2 } from "lucide-react"; +import { toast } from "@/lib/toast"; export function GenerateButton({ userId }: { userId: string }) { - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(false); - const handleGenerate = async () => { - setLoading(true); - try { - const res = await fetch("/api/recommendations/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId }), - }); + const handleGenerate = async () => { + setLoading(true); + try { + const res = await fetch("/api/recommendations/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }); - if (!res.ok) { - const error = await res.json(); - alert(`Error: ${error.error}`); - } else { - alert("Recommendation generated successfully! Check Pending Approvals."); - // In a real app, we'd revalidate the path or update state - window.location.reload(); - } - } catch (error) { - console.error(error); - alert("Failed to generate recommendation."); - } finally { - setLoading(false); - } - }; + if (!res.ok) { + const error = await res.json(); + toast.error(`Error: ${error.error}`); + } else { + toast.success( + "Recommendation generated successfully! Check Pending Approvals.", + ); + // In a real app, we'd revalidate the path or update state + window.location.reload(); + } + } catch (error) { + console.error(error); + toast.error("Failed to generate recommendation."); + } finally { + setLoading(false); + } + }; - return ( - - ); + return ( + + ); } diff --git a/apps/admin/src/components/recommendations/recommendation-list.tsx b/apps/admin/src/components/recommendations/recommendation-list.tsx index 93b369e..c863e0d 100644 --- a/apps/admin/src/components/recommendations/recommendation-list.tsx +++ b/apps/admin/src/components/recommendations/recommendation-list.tsx @@ -2,116 +2,123 @@ import { useState } from "react"; import { Loader2, Check, X } from "lucide-react"; +import { toast } from "@/lib/toast"; type Recommendation = { - recommendation: { - id: string; - recommendationText: string; - activityPlan: string; - dietPlan: string; - createdAt: Date; - }; - user: { - firstName: string; - lastName: string; - }; + recommendation: { + id: string; + recommendationText: string; + activityPlan: string; + dietPlan: string; + createdAt: Date; + }; + user: { + firstName: string; + lastName: string; + }; }; export function RecommendationList({ - initialRecommendations, + initialRecommendations, }: { - initialRecommendations: Recommendation[]; + initialRecommendations: Recommendation[]; }) { - const [recommendations, setRecommendations] = useState(initialRecommendations); - const [processingId, setProcessingId] = useState(null); + const [recommendations, setRecommendations] = useState( + initialRecommendations, + ); + const [processingId, setProcessingId] = useState(null); - const handleAction = async (id: string, status: "approved" | "rejected") => { - setProcessingId(id); - try { - const res = await fetch("/api/recommendations/approve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - recommendationId: id, - status, - approvedBy: "admin_placeholder", // In real app, get from auth context - }), - }); + const handleAction = async (id: string, status: "approved" | "rejected") => { + setProcessingId(id); + try { + const res = await fetch("/api/recommendations/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + recommendationId: id, + status, + approvedBy: "admin_placeholder", // In real app, get from auth context + }), + }); - if (!res.ok) { - alert("Failed to update status"); - } else { - setRecommendations((prev) => - prev.filter((item) => item.recommendation.id !== id) - ); - } - } catch (error) { - console.error(error); - alert("Error processing request"); - } finally { - setProcessingId(null); - } - }; - - if (recommendations.length === 0) { - return

No pending recommendations.

; + if (!res.ok) { + toast.error("Failed to update status"); + } else { + toast.success( + `Recommendation ${status === "approved" ? "approved" : "rejected"}`, + ); + setRecommendations((prev) => + prev.filter((item) => item.recommendation.id !== id), + ); + } + } catch (error) { + console.error(error); + toast.error("Error processing request"); + } finally { + setProcessingId(null); } + }; - return ( -
    - {recommendations.map(({ recommendation, user }) => ( -
  • -
    -

    - For: {user.firstName} {user.lastName} -

    - - {new Date(recommendation.createdAt).toLocaleDateString()} - -
    -
    -
    - Advice:{" "} - {recommendation.recommendationText} -
    -
    - Activity:{" "} - {recommendation.activityPlan} -
    -
    - Diet: {recommendation.dietPlan} -
    -
    -
    - - -
    -
  • - ))} -
- ); + if (recommendations.length === 0) { + return

No pending recommendations.

; + } + + return ( +
    + {recommendations.map(({ recommendation, user }) => ( +
  • +
    +

    + For: {user.firstName} {user.lastName} +

    + + {new Date(recommendation.createdAt).toLocaleDateString()} + +
    +
    +
    + Advice:{" "} + {recommendation.recommendationText} +
    +
    + Activity:{" "} + {recommendation.activityPlan} +
    +
    + Diet:{" "} + {recommendation.dietPlan} +
    +
    +
    + + +
    +
  • + ))} +
+ ); } diff --git a/apps/admin/src/components/ui/dialog.tsx b/apps/admin/src/components/ui/dialog.tsx new file mode 100644 index 0000000..98ff3cd --- /dev/null +++ b/apps/admin/src/components/ui/dialog.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className = "", ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className = "", children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className = "", + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className = "", + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className = "", ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className = "", ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/admin/src/components/users/CreateUserModal.tsx b/apps/admin/src/components/users/CreateUserModal.tsx new file mode 100644 index 0000000..8bab871 --- /dev/null +++ b/apps/admin/src/components/users/CreateUserModal.tsx @@ -0,0 +1,477 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/lib/toast"; +import { + roleSelectionSchema, + basicInfoSchema, + clientInfoSchema, + invitationOptionsSchema, + mergeUserCreationData, + type RoleSelectionData, + type BasicInfoData, + type ClientInfoData, + type InvitationOptionsData, +} from "@/lib/validation/user-schemas"; +import { ChevronLeft, ChevronRight, Check } from "lucide-react"; + +interface CreateUserModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +type Step = 1 | 2 | 3 | 4; + +export function CreateUserModal({ + open, + onOpenChange, + onSuccess, +}: CreateUserModalProps) { + const [currentStep, setCurrentStep] = useState(1); + const [roleData, setRoleData] = useState(null); + const [basicInfo, setBasicInfo] = useState(null); + const [clientInfo, setClientInfo] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Step 1: Role Selection Form + const roleForm = useForm({ + resolver: zodResolver(roleSelectionSchema), + defaultValues: { role: "client" }, + }); + + // Step 2: Basic Info Form + const basicForm = useForm({ + resolver: zodResolver(basicInfoSchema), + defaultValues: { + firstName: "", + lastName: "", + phone: "", + email: "", + }, + }); + + // Step 3: Client Info Form + const clientForm = useForm({ + resolver: zodResolver(clientInfoSchema), + defaultValues: { + membershipType: "basic", + membershipStatus: "active", + sendWelcomeEmail: false, + }, + }); + + // Step 4: Invitation Options Form + const invitationForm = useForm({ + resolver: zodResolver(invitationOptionsSchema), + defaultValues: { + sendInvitation: true, + gymId: "", + }, + }); + + const handleRoleNext = async () => { + const isValid = await roleForm.trigger(); + if (isValid) { + const data = roleForm.getValues(); + setRoleData(data); + setCurrentStep(2); + } + }; + + const handleBasicNext = async () => { + const isValid = await basicForm.trigger(); + if (isValid) { + const data = basicForm.getValues(); + setBasicInfo(data); + + // Determine next step based on role + if (roleData?.role === "client") { + setCurrentStep(3); + } else { + // For admin/trainer, skip client info + setCurrentStep(4); + } + } + }; + + const handleClientNext = async () => { + const isValid = await clientForm.trigger(); + if (isValid) { + const data = clientForm.getValues(); + setClientInfo(data); + + // If email provided, show invitation options + if (basicInfo?.email) { + setCurrentStep(4); + } else { + // No email, submit directly + await handleSubmit(); + } + } + }; + + const handleSubmit = async (invitationData?: InvitationOptionsData) => { + if (!roleData || !basicInfo) return; + + try { + setIsSubmitting(true); + + const payload = mergeUserCreationData( + roleData, + basicInfo, + clientInfo || undefined, + invitationData, + ); + + const response = await fetch("/api/users/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response.json(); + toast.error(error.error || "Failed to create user"); + return; + } + + const result = await response.json(); + toast.success(result.message || "User created successfully!"); + + // Reset form + resetModal(); + onSuccess(); + onOpenChange(false); + } catch (error) { + console.error("Create user error:", error); + toast.error("An unexpected error occurred"); + } finally { + setIsSubmitting(false); + } + }; + + const handleInvitationSubmit = async () => { + const isValid = await invitationForm.trigger(); + if (isValid) { + const data = invitationForm.getValues(); + await handleSubmit(data); + } + }; + + const resetModal = () => { + setCurrentStep(1); + setRoleData(null); + setBasicInfo(null); + setClientInfo(null); + roleForm.reset(); + basicForm.reset(); + clientForm.reset(); + invitationForm.reset(); + }; + + const handleBack = () => { + if (currentStep === 4 && roleData?.role === "client") { + setCurrentStep(3); + } else if (currentStep === 4 || currentStep === 3) { + setCurrentStep(2); + } else if (currentStep === 2) { + setCurrentStep(1); + } + }; + + const getStepTitle = () => { + switch (currentStep) { + case 1: + return "Select Role"; + case 2: + return "Basic Information"; + case 3: + return "Membership Details"; + case 4: + return "Invitation Options"; + default: + return ""; + } + }; + + const getStepDescription = () => { + switch (currentStep) { + case 1: + return "Choose the role for this user"; + case 2: + return "Enter user's basic information"; + case 3: + return "Configure client membership"; + case 4: + return basicInfo?.email + ? "Choose how to notify the user" + : "Review and create"; + default: + return ""; + } + }; + + return ( + + + + {getStepTitle()} + {getStepDescription()} + + + {/* Step Progress Indicator */} +
+ {[1, 2, 3, 4].map((step) => ( +
+ {step < currentStep ? : step} +
+ ))} +
+ + {/* Step 1: Role Selection */} + {currentStep === 1 && ( +
+
+ +
+ {(["admin", "trainer", "client"] as const).map((role) => ( + + ))} +
+ {roleForm.formState.errors.role && ( +

+ {roleForm.formState.errors.role.message} +

+ )} +
+ + + +
+ )} + + {/* Step 2: Basic Information */} + {currentStep === 2 && ( +
+
+ + + {basicForm.formState.errors.firstName && ( +

+ {basicForm.formState.errors.firstName.message} +

+ )} +
+
+ + + {basicForm.formState.errors.lastName && ( +

+ {basicForm.formState.errors.lastName.message} +

+ )} +
+
+ + + {basicForm.formState.errors.phone && ( +

+ {basicForm.formState.errors.phone.message} +

+ )} +
+
+ + +

+ Email is currently required in the database schema +

+ {basicForm.formState.errors.email && ( +

+ {basicForm.formState.errors.email.message} +

+ )} +
+ + + + +
+ )} + + {/* Step 3: Client Information (only for clients) */} + {currentStep === 3 && roleData?.role === "client" && ( +
+
+ + + {clientForm.formState.errors.membershipType && ( +

+ {clientForm.formState.errors.membershipType.message} +

+ )} +
+
+ + +
+ {basicInfo?.email && ( +
+ + +
+ )} + + + + +
+ )} + + {/* Step 4: Invitation Options */} + {currentStep === 4 && ( +
+ {basicInfo?.email && ( + <> +
+

+ Email provided: {basicInfo.email} +

+
+
+ + +
+

+ {invitationForm.watch("sendInvitation") + ? "User will receive an email to create their account" + : "User record will be created without a Clerk account"} +

+ + )} + {!basicInfo?.email && ( +
+

+ No email provided. User will be created in the database only. +

+
+ )} + + + + +
+ )} +
+
+ ); +} diff --git a/apps/admin/src/components/users/Recommendations.tsx b/apps/admin/src/components/users/Recommendations.tsx index 0490bd0..48651c2 100644 --- a/apps/admin/src/components/users/Recommendations.tsx +++ b/apps/admin/src/components/users/Recommendations.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardHeader, CardContent } from "@/components/ui/card"; import log from "@/lib/logger"; +import { toast } from "@/lib/toast"; interface Recommendation { id: string; @@ -63,8 +64,9 @@ export function Recommendations({ userId }: RecommendationsProps) { if (response.ok) { setNewRec({ ...newRec, content: "" }); fetchRecommendations(); + toast.success("Recommendation added successfully"); } else { - alert("Failed to add recommendation"); + toast.error("Failed to add recommendation"); } } catch (error) { log.error("Failed to add recommendation", error); diff --git a/apps/admin/src/components/users/UserManagement.tsx b/apps/admin/src/components/users/UserManagement.tsx index 13b75f1..cf849c0 100644 --- a/apps/admin/src/components/users/UserManagement.tsx +++ b/apps/admin/src/components/users/UserManagement.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; import { useUser } from "@clerk/nextjs"; import { getGymIdFromUser } from "@/lib/error-helpers"; import log from "@/lib/logger"; +import { toast } from "@/lib/toast"; +import { CreateUserModal } from "./CreateUserModal"; interface User { id: string; @@ -40,6 +42,7 @@ export function UserManagement() { const [selectedUser, setSelectedUser] = useState(null); const [isEditing, setIsEditing] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [createModalOpen, setCreateModalOpen] = useState(false); const [editForm, setEditForm] = useState<{ firstName: string; lastName: string; @@ -148,8 +151,9 @@ export function UserManagement() { }); if (response.ok) { fetchUsers(); + toast.success("Users deleted successfully"); } else { - alert("Error deleting users"); + toast.error("Error deleting users"); } } catch (error) { log.error("Failed to delete users", error); @@ -255,12 +259,13 @@ export function UserManagement() { // Still re-fetch from server to ensure consistency log.debug("Re-fetching users after successful edit"); fetchUsers(); + toast.success("User updated successfully"); } else { const errText = await response.text().catch(() => ""); log.error("User update failed", new Error(errText), { status: response.status, }); - alert("Error updating user"); + toast.error("Error updating user"); } } else { // Create (Invite) new user @@ -278,15 +283,15 @@ export function UserManagement() { setIsEditing(false); setEditForm(null); fetchUsers(); - alert("Invitation sent successfully!"); + toast.success("Invitation sent successfully!"); } else { const errorData = await response.json(); - alert(`Error sending invitation: ${errorData.error}`); + toast.error(`Error sending invitation: ${errorData.error}`); } } } catch (error) { console.error(error); - alert("An unexpected error occurred"); + toast.error("An unexpected error occurred"); } }; @@ -301,8 +306,9 @@ export function UserManagement() { setIsDeleting(false); setSelectedUser(null); fetchUsers(); + toast.success("User deleted successfully"); } else { - alert("Error deleting user"); + toast.error("Error deleting user"); } } catch (error) { log.error("Failed to delete user", error); @@ -327,22 +333,8 @@ export function UserManagement() { > Edit User -
); } diff --git a/apps/admin/src/emails/recommendation-email.tsx b/apps/admin/src/emails/recommendation-email.tsx new file mode 100644 index 0000000..03b56a4 --- /dev/null +++ b/apps/admin/src/emails/recommendation-email.tsx @@ -0,0 +1,99 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, +} from "@react-email/components"; + +interface RecommendationEmailProps { + clientName: string; + recommendationText: string; +} + +export function RecommendationEmail({ + clientName, + recommendationText, +}: RecommendationEmailProps) { + return ( + + + New fitness recommendation from your trainer + + + New Recommendation! 📋 + Hi {clientName}, + + Your trainer has created a new personalized recommendation for you: + +
+ {recommendationText} +
+ + Open your FitAI mobile app to view the complete recommendation + including your activity plan and nutrition guidance. + + + Keep up the great work! +
+ The FitAI Team +
+
+ + + ); +} + +// Default export for email previews +export default RecommendationEmail; + +// Styles +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "20px 0 48px", + marginBottom: "64px", + maxWidth: "600px", +}; + +const h1 = { + color: "#333", + fontSize: "28px", + fontWeight: "bold", + margin: "40px 0 20px", + padding: "0 40px", +}; + +const text = { + color: "#333", + fontSize: "16px", + lineHeight: "26px", + padding: "0 40px", + margin: "16px 0", +}; + +const section = { + padding: "20px 40px", + backgroundColor: "#f0fdf4", + margin: "20px 0", + borderLeft: "4px solid #10b981", + borderRadius: "4px", +}; + +const recommendationContent = { + color: "#333", + fontSize: "16px", + lineHeight: "26px", + margin: "0", + padding: "0", + fontStyle: "italic" as const, +}; diff --git a/apps/admin/src/emails/trainer-assignment-email.tsx b/apps/admin/src/emails/trainer-assignment-email.tsx new file mode 100644 index 0000000..b80d940 --- /dev/null +++ b/apps/admin/src/emails/trainer-assignment-email.tsx @@ -0,0 +1,117 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, +} from "@react-email/components"; + +interface TrainerAssignmentEmailProps { + clientName: string; + trainerName: string; +} + +export function TrainerAssignmentEmail({ + clientName, + trainerName, +}: TrainerAssignmentEmailProps) { + return ( + + + You've been assigned a trainer - {trainerName} + + + Great News! 🎯 + Hi {clientName}, + + You've been assigned a personal trainer to help you reach your + fitness goals! + +
+ Your Trainer + {trainerName} +
+ + Your trainer will work with you to: +
+ • Create personalized workout plans +
+ • Provide nutrition guidance +
+ • Track your progress and achievements +
• Adjust your program as you improve +
+ + Check your FitAI mobile app to view your trainer's profile and start + receiving personalized recommendations. + + + Let's crush those goals together! +
+ The FitAI Team +
+
+ + + ); +} + +// Default export for email previews +export default TrainerAssignmentEmail; + +// Styles +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "20px 0 48px", + marginBottom: "64px", + maxWidth: "600px", +}; + +const h1 = { + color: "#333", + fontSize: "28px", + fontWeight: "bold", + margin: "40px 0 20px", + padding: "0 40px", +}; + +const h2 = { + color: "#333", + fontSize: "20px", + fontWeight: "bold", + margin: "10px 0", + padding: "0", +}; + +const text = { + color: "#333", + fontSize: "16px", + lineHeight: "26px", + padding: "0 40px", + margin: "16px 0", +}; + +const section = { + padding: "20px 40px", + backgroundColor: "#eff6ff", + margin: "20px 0", + borderLeft: "4px solid #3b82f6", +}; + +const highlightText = { + color: "#3b82f6", + fontSize: "24px", + fontWeight: "bold", + margin: "0", + padding: "0", +}; diff --git a/apps/admin/src/emails/user-invitation-email.tsx b/apps/admin/src/emails/user-invitation-email.tsx new file mode 100644 index 0000000..c0d6aa4 --- /dev/null +++ b/apps/admin/src/emails/user-invitation-email.tsx @@ -0,0 +1,133 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + Button, + Link, +} from "@react-email/components"; + +interface UserInvitationEmailProps { + email: string; + role: string; + inviteUrl: string; +} + +export function UserInvitationEmail({ + email, + role, + inviteUrl, +}: UserInvitationEmailProps) { + const roleDisplay = + role === "admin" + ? "Administrator" + : role === "trainer" + ? "Trainer" + : "Client"; + + return ( + + + You've been invited to join FitAI as a {roleDisplay} + + + Welcome to FitAI! 🎉 + + You've been invited to join FitAI as a{" "} + {roleDisplay}. + + + Click the button below to accept your invitation and create your + account: + +
+ +
+ + Or copy and paste this URL into your browser: + + + {inviteUrl} + + + This invitation was sent to {email}. If you didn't expect this + email, you can safely ignore it. + + The FitAI Team +
+ + + ); +} + +// Default export for email previews +export default UserInvitationEmail; + +// Styles +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "20px 0 48px", + marginBottom: "64px", + maxWidth: "600px", +}; + +const h1 = { + color: "#333", + fontSize: "28px", + fontWeight: "bold", + margin: "40px 0 20px", + padding: "0 40px", +}; + +const text = { + color: "#333", + fontSize: "16px", + lineHeight: "26px", + padding: "0 40px", + margin: "16px 0", +}; + +const buttonContainer = { + padding: "27px 40px", + textAlign: "center" as const, +}; + +const button = { + backgroundColor: "#3b82f6", + borderRadius: "5px", + color: "#fff", + fontSize: "16px", + fontWeight: "bold", + textDecoration: "none", + textAlign: "center" as const, + display: "inline-block", + padding: "12px 32px", +}; + +const link = { + color: "#3b82f6", + fontSize: "14px", + padding: "0 40px", + wordBreak: "break-all" as const, +}; + +const footer = { + color: "#333", + fontSize: "16px", + lineHeight: "26px", + padding: "0 40px", + marginTop: "32px", +}; diff --git a/apps/admin/src/emails/welcome-email.tsx b/apps/admin/src/emails/welcome-email.tsx new file mode 100644 index 0000000..653fd2b --- /dev/null +++ b/apps/admin/src/emails/welcome-email.tsx @@ -0,0 +1,101 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + Button, +} from "@react-email/components"; + +interface WelcomeEmailProps { + name: string; +} + +export function WelcomeEmail({ name }: WelcomeEmailProps) { + return ( + + + Welcome to FitAI - Your fitness journey starts here! + + + Welcome to FitAI! 💪 + Hi {name}, + + We're excited to have you join FitAI! Your account has been + successfully created, and you're all set to begin your fitness + journey. + +
+ Getting Started + + • Download the FitAI mobile app to track your workouts +
+ • Complete your fitness profile for personalized recommendations +
+ • Check in at the gym to start tracking your attendance +
• Connect with your trainer for custom workout plans +
+
+ + If you have any questions, feel free to reach out to your gym + administrator or trainer. + + + Let's make fitness a lifestyle! +
+ The FitAI Team +
+
+ + + ); +} + +// Default export for email previews +export default WelcomeEmail; + +// Styles +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "20px 0 48px", + marginBottom: "64px", + maxWidth: "600px", +}; + +const h1 = { + color: "#333", + fontSize: "28px", + fontWeight: "bold", + margin: "40px 0 20px", + padding: "0 40px", +}; + +const h2 = { + color: "#333", + fontSize: "20px", + fontWeight: "bold", + margin: "20px 0 10px", +}; + +const text = { + color: "#333", + fontSize: "16px", + lineHeight: "26px", + padding: "0 40px", +}; + +const section = { + padding: "20px 40px", + backgroundColor: "#f8f9fa", + margin: "20px 0", +}; diff --git a/apps/admin/src/lib/email.ts b/apps/admin/src/lib/email.ts new file mode 100644 index 0000000..35041bf --- /dev/null +++ b/apps/admin/src/lib/email.ts @@ -0,0 +1,148 @@ +import { Resend } from "resend"; +import { render } from "@react-email/render"; +import log from "./logger"; + +/** + * Email service using Resend + * + * Environment variables required: + * - RESEND_API_KEY: API key from Resend dashboard + * - EMAIL_FROM: Sender email address (e.g., "FitAI ") + * - EMAIL_REPLY_TO: Reply-to email address (optional) + */ + +const resend = new Resend(process.env.RESEND_API_KEY); + +const DEFAULT_FROM = process.env.EMAIL_FROM || "FitAI "; +const DEFAULT_REPLY_TO = process.env.EMAIL_REPLY_TO; + +export interface SendEmailOptions { + to: string | string[]; + subject: string; + react: React.ReactElement; + from?: string; + replyTo?: string; +} + +/** + * Send an email using Resend + * + * @param options - Email options including recipient, subject, and React component + * @returns Response from Resend API + * + * @example + * ```tsx + * import { WelcomeEmail } from '@/emails/welcome-email'; + * + * await sendEmail({ + * to: 'user@example.com', + * subject: 'Welcome to FitAI', + * react: + * }); + * ``` + */ +export async function sendEmail({ + to, + subject, + react, + from = DEFAULT_FROM, + replyTo = DEFAULT_REPLY_TO, +}: SendEmailOptions) { + try { + // Check if email is configured + if (!process.env.RESEND_API_KEY) { + log.warn("RESEND_API_KEY not configured. Email not sent.", { + to, + subject, + }); + return { success: false, error: "Email not configured" }; + } + + const { data, error } = await resend.emails.send({ + from, + to, + subject, + react, + ...(replyTo && { replyTo }), + }); + + if (error) { + log.error("Failed to send email", error, { to, subject }); + return { success: false, error: error.message }; + } + + log.info("Email sent successfully", { to, subject, emailId: data?.id }); + return { success: true, data }; + } catch (error) { + log.error("Email service error", error, { to, subject }); + return { success: false, error: "Email service error" }; + } +} + +/** + * Send a welcome email to a new user + */ +export async function sendWelcomeEmail(email: string, name: string) { + const { WelcomeEmail } = await import("@/emails/welcome-email"); + + return sendEmail({ + to: email, + subject: "Welcome to FitAI!", + react: WelcomeEmail({ name }), + }); +} + +/** + * Send an invitation email to a new user + */ +export async function sendInvitationEmail( + email: string, + role: string, + inviteUrl: string, +) { + const { UserInvitationEmail } = await import( + "@/emails/user-invitation-email" + ); + + return sendEmail({ + to: email, + subject: "You've been invited to join FitAI", + react: UserInvitationEmail({ email, role, inviteUrl }), + }); +} + +/** + * Send a trainer assignment notification email + */ +export async function sendTrainerAssignmentEmail( + clientEmail: string, + clientName: string, + trainerName: string, +) { + const { TrainerAssignmentEmail } = await import( + "@/emails/trainer-assignment-email" + ); + + return sendEmail({ + to: clientEmail, + subject: "You've been assigned a trainer", + react: TrainerAssignmentEmail({ clientName, trainerName }), + }); +} + +/** + * Send a recommendation notification email to a client + */ +export async function sendRecommendationEmail( + clientEmail: string, + clientName: string, + recommendationText: string, +) { + const { RecommendationEmail } = await import("@/emails/recommendation-email"); + + return sendEmail({ + to: clientEmail, + subject: "New fitness recommendation from your trainer", + react: RecommendationEmail({ clientName, recommendationText }), + }); +} diff --git a/apps/admin/src/lib/toast.ts b/apps/admin/src/lib/toast.ts new file mode 100644 index 0000000..c7253ad --- /dev/null +++ b/apps/admin/src/lib/toast.ts @@ -0,0 +1,77 @@ +import { toast as sonnerToast } from "sonner"; + +/** + * Toast notification utility wrapper around Sonner + * + * Provides a consistent API for showing toast notifications throughout the app + */ +export const toast = { + /** + * Show a success toast notification + * @param message - The message to display + * @param description - Optional description text + */ + success: (message: string, description?: string) => { + return sonnerToast.success(message, { description }); + }, + + /** + * Show an error toast notification + * @param message - The message to display + * @param description - Optional description text + */ + error: (message: string, description?: string) => { + return sonnerToast.error(message, { description }); + }, + + /** + * Show an info toast notification + * @param message - The message to display + * @param description - Optional description text + */ + info: (message: string, description?: string) => { + return sonnerToast.info(message, { description }); + }, + + /** + * Show a warning toast notification + * @param message - The message to display + * @param description - Optional description text + */ + warning: (message: string, description?: string) => { + return sonnerToast.warning(message, { description }); + }, + + /** + * Show a loading toast notification + * @param message - The message to display + * @returns Toast ID that can be used to update or dismiss the toast + */ + loading: (message: string) => { + return sonnerToast.loading(message); + }, + + /** + * Show a promise toast that updates based on promise state + * @param promise - The promise to track + * @param messages - Messages for loading, success, and error states + */ + promise: ( + promise: Promise, + messages: { + loading: string; + success: string | ((data: T) => string); + error: string | ((error: Error) => string); + }, + ) => { + return sonnerToast.promise(promise, messages); + }, + + /** + * Dismiss a specific toast by ID or all toasts + * @param toastId - Optional toast ID to dismiss. If not provided, dismisses all toasts + */ + dismiss: (toastId?: string | number) => { + return sonnerToast.dismiss(toastId); + }, +}; diff --git a/apps/admin/src/lib/validation/user-schemas.ts b/apps/admin/src/lib/validation/user-schemas.ts new file mode 100644 index 0000000..7abe2af --- /dev/null +++ b/apps/admin/src/lib/validation/user-schemas.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +/** + * Validation schemas for user creation flow + */ + +// Step 1: Role Selection +export const roleSelectionSchema = z.object({ + role: z.enum(["admin", "trainer", "client"], { + message: "Please select a role", + }), +}); + +export type RoleSelectionData = z.infer; + +// Step 2: Basic Information +export const basicInfoSchema = z.object({ + firstName: z + .string() + .min(2, "First name must be at least 2 characters") + .max(50, "First name must be less than 50 characters"), + lastName: z + .string() + .min(2, "Last name must be at least 2 characters") + .max(50, "Last name must be less than 50 characters"), + phone: z + .string() + .regex(/^\+?[1-9]\d{1,14}$/, "Please enter a valid phone number") + .optional() + .or(z.literal("")), + email: z.string().email("Please enter a valid email address"), +}); + +export type BasicInfoData = z.infer; + +// Step 3: Client-Specific Information (only for clients) +export const clientInfoSchema = z.object({ + membershipType: z.enum(["basic", "premium", "vip"], { + message: "Please select a membership type", + }), + membershipStatus: z.enum(["active", "inactive", "suspended"]), + sendWelcomeEmail: z.boolean(), +}); + +export type ClientInfoData = z.infer; + +// Step 4: Invitation Options (for admin/trainer with email) +export const invitationOptionsSchema = z.object({ + sendInvitation: z.boolean(), + gymId: z.string().optional(), +}); + +export type InvitationOptionsData = z.infer; + +// Complete user creation payload +export const createUserSchema = z.object({ + role: z.enum(["admin", "trainer", "client"]), + firstName: z.string().min(2).max(50), + lastName: z.string().min(2).max(50), + phone: z.string().optional(), + email: z.string().email(), + + // Client-specific fields + membershipType: z.enum(["basic", "premium", "vip"]).optional(), + membershipStatus: z.enum(["active", "inactive", "suspended"]).optional(), + + // Invitation options + sendInvitation: z.boolean(), + sendWelcomeEmail: z.boolean(), + gymId: z.string().optional(), +}); + +export type CreateUserData = z.infer; + +/** + * Helper function to validate and merge form data from all steps + */ +export function mergeUserCreationData( + roleData: RoleSelectionData, + basicInfo: BasicInfoData, + clientInfo?: ClientInfoData, + invitationOptions?: InvitationOptionsData, +): CreateUserData { + const merged: CreateUserData = { + role: roleData.role, + firstName: basicInfo.firstName, + lastName: basicInfo.lastName, + phone: basicInfo.phone || undefined, + email: basicInfo.email, + sendInvitation: false, + sendWelcomeEmail: false, + }; + + // Add client-specific data + if (roleData.role === "client" && clientInfo) { + merged.membershipType = clientInfo.membershipType; + merged.membershipStatus = clientInfo.membershipStatus; + merged.sendWelcomeEmail = clientInfo.sendWelcomeEmail; + } + + // Add invitation options for admin/trainer + if ( + (roleData.role === "admin" || roleData.role === "trainer") && + invitationOptions + ) { + merged.sendInvitation = invitationOptions.sendInvitation; + merged.gymId = invitationOptions.gymId; + } + + return createUserSchema.parse(merged); +} diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example index 354d2ae..f6c3860 100644 --- a/apps/mobile/.env.example +++ b/apps/mobile/.env.example @@ -1,14 +1,41 @@ +# ============================================================================= +# FitAI Mobile App Environment Configuration +# ============================================================================= +# Copy this file to .env and fill in the values +# All variables prefixed with EXPO_PUBLIC_ are accessible in the app code + +# ============================================================================= # Clerk Authentication -# Get these values from https://dashboard.clerk.com -# Make sure to use the correct publishable key for your environment -# IMPORTANT: Must start with EXPO_PUBLIC_ to be available in the app +# ============================================================================= +# Get your publishable key from https://dashboard.clerk.com +# Navigate to: API Keys > Copy Publishable Key +# IMPORTANT: Use the correct key for your environment (development/production) EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here +# ============================================================================= # API Configuration -# Development: Use ngrok or local network IP (e.g., http://192.168.1.100:3000) -# Production: Use your production API URL -EXPO_PUBLIC_API_URL=http://localhost:3000/api +# ============================================================================= +# Backend API base URL (without /api suffix) +# +# Options: +# 1. Local development (emulator/simulator on same machine): +# EXPO_PUBLIC_API_URL=http://localhost:3000 +# +# 2. Physical device or external testing (using ngrok): +# EXPO_PUBLIC_API_URL=https://your-ngrok-url.ngrok-free.app +# Start ngrok: ngrok http 3000 +# +# 3. Local network (physical device on same WiFi): +# EXPO_PUBLIC_API_URL=http://192.168.1.100:3000 +# Replace with your computer's local IP address +# +# 4. Production: +# EXPO_PUBLIC_API_URL=https://api.yourapp.com +# +EXPO_PUBLIC_API_URL=http://localhost:3000 -# App Configuration +# ============================================================================= +# App Configuration (Optional) +# ============================================================================= EXPO_PUBLIC_APP_NAME=FitAI EXPO_PUBLIC_APP_VERSION=1.0.0 diff --git a/apps/mobile/src/api/fitnessProfile.ts b/apps/mobile/src/api/fitnessProfile.ts index 1e1c22f..caabe9b 100644 --- a/apps/mobile/src/api/fitnessProfile.ts +++ b/apps/mobile/src/api/fitnessProfile.ts @@ -5,47 +5,57 @@ import log from "../utils/logger"; export interface FitnessProfile { id?: string; - clientId: string; + userId: string; height?: number; weight?: number; - goals?: string; - fitnessLevel: "beginner" | "intermediate" | "advanced"; + age?: number; + gender?: "male" | "female" | "other" | "prefer_not_to_say"; + fitnessGoals?: string[]; + activityLevel?: + | "sedentary" + | "lightly_active" + | "moderately_active" + | "very_active" + | "extremely_active"; medicalConditions?: string; - dietaryRestrictions?: string; - preferredWorkoutTime?: "morning" | "afternoon" | "evening"; - workoutFrequency?: number; + allergies?: string; + injuries?: string; + preferences?: string; } export const fitnessProfileApi = { - getFitnessProfile: async ( - userId: string, - token: string, - ): Promise => { + getFitnessProfile: async (token: string): Promise => { try { - log.debug("Getting fitness profile", { userId }); - const response = await apiClient.get( - `${API_ENDPOINTS.USERS}/${userId}/fitness-profile`, - { - headers: { - Authorization: `Bearer ${token}`, - }, + log.debug("Getting fitness profile"); + const response = await apiClient.get(`${API_ENDPOINTS.PROFILE.FITNESS}`, { + headers: { + Authorization: `Bearer ${token}`, }, - ); - return response.data; + }); + return response.data.profile || null; } catch (error: unknown) { log.error("Failed to fetch fitness profile", error); throw error; } }, + checkProfileExists: async (token: string): Promise => { + try { + const profile = await fitnessProfileApi.getFitnessProfile(token); + return profile !== null; + } catch (error: unknown) { + log.error("Failed to check if fitness profile exists", error); + return false; + } + }, + updateFitnessProfile: async ( - userId: string, data: Partial, token: string, ): Promise => { try { const response = await apiClient.put( - `${API_ENDPOINTS.USERS}/${userId}/fitness-profile`, + `${API_ENDPOINTS.PROFILE.FITNESS}`, data, { headers: { @@ -66,7 +76,7 @@ export const fitnessProfileApi = { ): Promise => { try { const response = await apiClient.post( - `${API_ENDPOINTS.USERS}/${data.clientId}/fitness-profile`, + `${API_ENDPOINTS.PROFILE.FITNESS}`, data, { headers: { diff --git a/apps/mobile/src/app/(auth)/onboarding.tsx b/apps/mobile/src/app/(auth)/onboarding.tsx index 6f87080..2c76b18 100644 --- a/apps/mobile/src/app/(auth)/onboarding.tsx +++ b/apps/mobile/src/app/(auth)/onboarding.tsx @@ -12,6 +12,7 @@ import { import { useUser, useAuth } from "@clerk/clerk-expo"; import { useRouter } from "expo-router"; import { fitnessProfileApi } from "@/api/fitnessProfile"; +import { API_BASE_URL } from "@/config/api"; import log from "../../utils/logger"; export default function OnboardingScreen() { @@ -30,7 +31,7 @@ export default function OnboardingScreen() { try { setGymsLoading(true); const token = await getToken(); - const res = await fetch("/api/gyms", { + const res = await fetch(`${API_BASE_URL}/api/gyms`, { headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); const data = await res.json(); @@ -48,6 +49,7 @@ export default function OnboardingScreen() { const [fitnessProfile, setFitnessProfile] = useState({ height: "", weight: "", + age: "", goals: "", fitnessLevel: "beginner", medicalConditions: "", @@ -63,8 +65,12 @@ export default function OnboardingScreen() { setIsSubmitting(true); // Validate required fields - if (!fitnessProfile.height || !fitnessProfile.weight) { - Alert.alert("Error", "Please enter your height and weight"); + if ( + !fitnessProfile.height || + !fitnessProfile.weight || + !fitnessProfile.age + ) { + Alert.alert("Error", "Please enter your height, weight, and age"); return; } @@ -76,7 +82,7 @@ export default function OnboardingScreen() { // If gym was selected or cleared, patch user's gym selection first // selectedGymId: string gym id, or null to proceed without gym try { - await fetch("/api/users/gym", { + await fetch(`${API_BASE_URL}/api/users/gym`, { method: "PATCH", headers: { "Content-Type": "application/json", @@ -89,21 +95,13 @@ export default function OnboardingScreen() { } const fitnessData = { - clientId: user.id, + userId: user.id, height: parseFloat(fitnessProfile.height), weight: parseFloat(fitnessProfile.weight), - goals: fitnessProfile.goals || undefined, - fitnessLevel: fitnessProfile.fitnessLevel as - | "beginner" - | "intermediate" - | "advanced", + age: parseInt(fitnessProfile.age), + fitnessGoals: fitnessProfile.goals ? [fitnessProfile.goals] : [], medicalConditions: fitnessProfile.medicalConditions || undefined, - dietaryRestrictions: fitnessProfile.dietaryRestrictions || undefined, - preferredWorkoutTime: fitnessProfile.preferredWorkoutTime as - | "morning" - | "afternoon" - | "evening", - workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3, + allergies: fitnessProfile.dietaryRestrictions || undefined, }; await fitnessProfileApi.createFitnessProfile(fitnessData, token); @@ -127,6 +125,24 @@ export default function OnboardingScreen() { setFitnessProfile({ ...fitnessProfile, preferredWorkoutTime: time }); }; + // Calculate progress based on filled fields + const calculateProgress = () => { + const fields = [ + fitnessProfile.height, + fitnessProfile.weight, + fitnessProfile.age, + fitnessProfile.goals, + fitnessProfile.medicalConditions || "n/a", // Optional fields count as filled + fitnessProfile.dietaryRestrictions || "n/a", + ]; + const filledFields = fields.filter( + (field) => field && field.trim() !== "", + ).length; + return Math.round((filledFields / fields.length) * 100); + }; + + const progress = calculateProgress(); + return ( Set Up Your Fitness Profile @@ -134,6 +150,14 @@ export default function OnboardingScreen() { Help us personalize your fitness journey + {/* Progress indicator */} + + + + + {progress}% Complete + + Height (cm) + Age + + setFitnessProfile({ ...fitnessProfile, age: value }) + } + keyboardType="numeric" + placeholder="Enter your age" + /> + Fitness Goals { if (isSignedIn) { - router.replace("/(tabs)"); + router.replace("/(auth)/onboarding"); } }, [isSignedIn]); @@ -76,7 +76,7 @@ export default function SignUpScreen() { if (completeSignUp.status === "complete") { await setActive({ session: completeSignUp.createdSessionId }); - router.replace("/(tabs)"); + router.replace("/(auth)/onboarding"); } else { log.warn("Verification incomplete", { status: completeSignUp.status }); setError("Verification incomplete. Please try again."); @@ -87,7 +87,7 @@ export default function SignUpScreen() { // Handle specific error codes if (getClerkErrorCode(err) === "session_exists") { // User is already signed in, just redirect - router.replace("/(tabs)"); + router.replace("/(auth)/onboarding"); return; } diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index e8697a6..717a189 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,12 +1,15 @@ import { Tabs, useRouter, useSegments } from "expo-router"; import { useAuth } from "@clerk/clerk-expo"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { CustomTabBar } from "../../components/CustomTabBar"; +import { fitnessProfileApi } from "../../api/fitnessProfile"; +import log from "../../utils/logger"; export default function TabLayout() { - const { isSignedIn, isLoaded } = useAuth(); + const { isSignedIn, isLoaded, getToken } = useAuth(); const router = useRouter(); const segments = useSegments(); + const [onboardingChecked, setOnboardingChecked] = useState(false); useEffect(() => { if (!isLoaded) return; @@ -16,10 +19,40 @@ export default function TabLayout() { if (!isSignedIn && !inAuthGroup) { // Redirect to sign-in if not authenticated router.replace("/(auth)/sign-in"); + return; + } + + // Check if user has completed onboarding + if (isSignedIn && !onboardingChecked) { + checkOnboardingStatus(); } }, [isSignedIn, isLoaded, segments]); - if (!isLoaded || !isSignedIn) { + const checkOnboardingStatus = async () => { + try { + const token = await getToken(); + if (!token) { + log.warn("No token available for onboarding check"); + return; + } + + const hasProfile = await fitnessProfileApi.checkProfileExists(token); + + if (!hasProfile) { + // User hasn't completed onboarding, redirect to onboarding screen + log.info("User has not completed onboarding, redirecting"); + router.replace("/(auth)/onboarding"); + } else { + setOnboardingChecked(true); + } + } catch (error) { + log.error("Failed to check onboarding status", error); + // On error, allow access (fail open to prevent blocking legitimate users) + setOnboardingChecked(true); + } + }; + + if (!isLoaded || !isSignedIn || !onboardingChecked) { return null; } diff --git a/apps/mobile/src/config/api.ts b/apps/mobile/src/config/api.ts index cac9e5c..7f43c9c 100644 --- a/apps/mobile/src/config/api.ts +++ b/apps/mobile/src/config/api.ts @@ -29,7 +29,7 @@ export const API_ENDPOINTS = { REGISTER: "/api/auth/register", }, PROFILE: { - FITNESS: "/api/profile/fitness", + FITNESS: "/api/fitness-profile", }, CLIENTS: "/api/clients", USERS: "/api/users",