p 1 and 2 compeleted
This commit is contained in:
parent
6adb541967
commit
3573709d99
@ -8,6 +8,12 @@ CLERK_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
|||||||
# Get your API key from https://platform.deepseek.com/
|
# Get your API key from https://platform.deepseek.com/
|
||||||
DEEPSEEK_API_KEY=sk-your_deepseek_api_key_here
|
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 <noreply@yourdomain.com>
|
||||||
|
EMAIL_REPLY_TO=support@yourdomain.com
|
||||||
|
|
||||||
# Database (optional - defaults to ./fitai.db)
|
# Database (optional - defaults to ./fitai.db)
|
||||||
DATABASE_PATH=./fitai.db
|
DATABASE_PATH=./fitai.db
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
606
apps/admin/package-lock.json
generated
606
apps/admin/package-lock.json
generated
@ -14,6 +14,8 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@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",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/sqlite3": "^5.1.0",
|
"@types/sqlite3": "^5.1.0",
|
||||||
@ -39,6 +41,8 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
|
"resend": "^6.9.3",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"svix": "^1.81.0",
|
"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": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.10.1",
|
"version": "2.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz",
|
||||||
@ -2813,6 +3166,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.41",
|
"version": "0.34.41",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
||||||
@ -5550,7 +5916,6 @@
|
|||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@ -5674,6 +6039,73 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@ -7459,6 +7891,53 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
@ -9463,6 +9942,15 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@ -9680,6 +10168,18 @@
|
|||||||
"tmpl": "1.0.5"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@ -10721,6 +11234,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -10909,6 +11431,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@ -11101,6 +11629,21 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
@ -11136,6 +11679,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"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==",
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@ -11864,6 +12437,18 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@ -12209,6 +12794,16 @@
|
|||||||
"atomic-sleep": "^1.0.0"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@ -12739,13 +13334,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svix": {
|
"node_modules/svix": {
|
||||||
"version": "1.81.0",
|
"version": "1.84.1",
|
||||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.81.0.tgz",
|
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
|
||||||
"integrity": "sha512-Q4DiYb1ydhRYqez65vZES8AkGY2oxn26qP7mLVbMf8Orrveb54TZLkaVG5zr7eJT4T3zYRThkKf6aOnvzgwhYw==",
|
"integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stablelib/base64": "^1.0.0",
|
"standardwebhooks": "1.0.0",
|
||||||
"fast-sha256": "^1.3.0",
|
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@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",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/sqlite3": "^5.1.0",
|
"@types/sqlite3": "^5.1.0",
|
||||||
@ -45,6 +47,8 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
|
"resend": "^6.9.3",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"svix": "^1.81.0",
|
"svix": "^1.81.0",
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const profile = db
|
const profile = db
|
||||||
.prepare(`SELECT * FROM fitness_profiles WHERE userId = ?`)
|
.prepare(`SELECT * FROM fitness_profiles WHERE user_id = ?`)
|
||||||
.get(userId);
|
.get(userId);
|
||||||
|
|
||||||
if (profile) {
|
if (profile) {
|
||||||
@ -78,8 +78,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Check if profile exists
|
// Check if profile exists
|
||||||
const existingProfile = db
|
const existingProfile = db
|
||||||
.prepare(`SELECT userId FROM fitness_profiles WHERE userId = ?`)
|
.prepare(`SELECT user_id FROM fitness_profiles WHERE user_id = ?`)
|
||||||
.get(userId) as { userId: string } | undefined;
|
.get(userId) as { user_id: string } | undefined;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const fitnessGoalsJson = JSON.stringify(fitnessGoals || []);
|
const fitnessGoalsJson = JSON.stringify(fitnessGoals || []);
|
||||||
@ -92,14 +92,13 @@ export async function POST(request: NextRequest) {
|
|||||||
weight = ?,
|
weight = ?,
|
||||||
age = ?,
|
age = ?,
|
||||||
gender = ?,
|
gender = ?,
|
||||||
fitnessGoals = ?,
|
fitness_goals = ?,
|
||||||
activityLevel = ?,
|
activity_level = ?,
|
||||||
medicalConditions = ?,
|
medical_conditions = ?,
|
||||||
allergies = ?,
|
allergies = ?,
|
||||||
injuries = ?,
|
injuries = ?,
|
||||||
preferences = ?,
|
updated_at = ?
|
||||||
updatedAt = ?
|
WHERE user_id = ?`,
|
||||||
WHERE userId = ?`,
|
|
||||||
).run(
|
).run(
|
||||||
height,
|
height,
|
||||||
weight,
|
weight,
|
||||||
@ -110,7 +109,6 @@ export async function POST(request: NextRequest) {
|
|||||||
medicalConditions || null,
|
medicalConditions || null,
|
||||||
allergies || null,
|
allergies || null,
|
||||||
injuries || null,
|
injuries || null,
|
||||||
preferences || null,
|
|
||||||
now,
|
now,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@ -121,12 +119,15 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new profile
|
// Create new profile
|
||||||
|
const profileId = `fp_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO fitness_profiles
|
`INSERT INTO fitness_profiles
|
||||||
(userId, height, weight, age, gender, fitnessGoals, activityLevel,
|
(id, user_id, height, weight, age, gender, fitness_goals, activity_level,
|
||||||
medicalConditions, allergies, injuries, preferences, createdAt, updatedAt)
|
medical_conditions, allergies, injuries, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
|
profileId,
|
||||||
userId,
|
userId,
|
||||||
height,
|
height,
|
||||||
weight,
|
weight,
|
||||||
@ -137,7 +138,6 @@ export async function POST(request: NextRequest) {
|
|||||||
medicalConditions || null,
|
medicalConditions || null,
|
||||||
allergies || null,
|
allergies || null,
|
||||||
injuries || null,
|
injuries || null,
|
||||||
preferences || null,
|
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|||||||
159
apps/admin/src/app/api/users/create/route.ts
Normal file
159
apps/admin/src/app/api/users/create/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -128,6 +128,30 @@ export async function POST(req: Request) {
|
|||||||
now,
|
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 this is a client invited by a trainer, create trainer-client link
|
||||||
if (roleAssigned === "client" && inviterUserId && gymId) {
|
if (roleAssigned === "client" && inviterUserId && gymId) {
|
||||||
const inviterRow = db
|
const inviterRow = db
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
UserButton,
|
UserButton,
|
||||||
} from "@clerk/nextjs";
|
} from "@clerk/nextjs";
|
||||||
import { Sidebar } from "@/components/ui/Sidebar";
|
import { Sidebar } from "@/components/ui/Sidebar";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@ -28,10 +29,9 @@ export default function RootLayout({
|
|||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<div className="flex min-h-screen bg-slate-50">
|
<div className="flex min-h-screen bg-slate-50">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 ml-20 p-8">
|
<main className="flex-1 ml-20 p-8">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useUser } from "@clerk/nextjs";
|
||||||
import log from "@/lib/logger";
|
import log from "@/lib/logger";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -68,14 +69,14 @@ export default function RecommendationsPage() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json();
|
const error = await res.json();
|
||||||
alert(`Error: ${error.error}`);
|
toast.error(`Error: ${error.error}`);
|
||||||
} else {
|
} else {
|
||||||
alert("Recommendation generated successfully!");
|
toast.success("Recommendation generated successfully!");
|
||||||
fetchData(); // Refresh data
|
fetchData(); // Refresh data
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to generate recommendation", error);
|
log.error("Failed to generate recommendation", error);
|
||||||
alert("Failed to generate recommendation.");
|
toast.error("Failed to generate recommendation.");
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(null);
|
setGenerating(null);
|
||||||
}
|
}
|
||||||
@ -98,13 +99,16 @@ export default function RecommendationsPage() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json();
|
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 {
|
} else {
|
||||||
|
toast.success("Recommendation status updated");
|
||||||
fetchData(); // Refresh data
|
fetchData(); // Refresh data
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to approve recommendation", 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) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json();
|
const errorData = await res.json();
|
||||||
alert(
|
toast.error(
|
||||||
`Failed to update recommendation: ${errorData.error || "Unknown error"}`,
|
`Failed to update recommendation: ${errorData.error || "Unknown error"}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
alert("Recommendation updated successfully!");
|
toast.success("Recommendation updated successfully!");
|
||||||
fetchData(); // Refresh data
|
fetchData(); // Refresh data
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to update recommendation", error);
|
log.error("Failed to update recommendation", error);
|
||||||
alert("Failed to update recommendation.");
|
toast.error("Failed to update recommendation.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,43 +2,46 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
export function GenerateButton({ userId }: { userId: string }) {
|
export function GenerateButton({ userId }: { userId: string }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/recommendations/generate", {
|
const res = await fetch("/api/recommendations/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ userId }),
|
body: JSON.stringify({ userId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = await res.json();
|
const error = await res.json();
|
||||||
alert(`Error: ${error.error}`);
|
toast.error(`Error: ${error.error}`);
|
||||||
} else {
|
} else {
|
||||||
alert("Recommendation generated successfully! Check Pending Approvals.");
|
toast.success(
|
||||||
// In a real app, we'd revalidate the path or update state
|
"Recommendation generated successfully! Check Pending Approvals.",
|
||||||
window.location.reload();
|
);
|
||||||
}
|
// In a real app, we'd revalidate the path or update state
|
||||||
} catch (error) {
|
window.location.reload();
|
||||||
console.error(error);
|
}
|
||||||
alert("Failed to generate recommendation.");
|
} catch (error) {
|
||||||
} finally {
|
console.error(error);
|
||||||
setLoading(false);
|
toast.error("Failed to generate recommendation.");
|
||||||
}
|
} finally {
|
||||||
};
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
|
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
|
||||||
>
|
>
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Generate
|
Generate
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,116 +2,123 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Loader2, Check, X } from "lucide-react";
|
import { Loader2, Check, X } from "lucide-react";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
type Recommendation = {
|
type Recommendation = {
|
||||||
recommendation: {
|
recommendation: {
|
||||||
id: string;
|
id: string;
|
||||||
recommendationText: string;
|
recommendationText: string;
|
||||||
activityPlan: string;
|
activityPlan: string;
|
||||||
dietPlan: string;
|
dietPlan: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
user: {
|
user: {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RecommendationList({
|
export function RecommendationList({
|
||||||
initialRecommendations,
|
initialRecommendations,
|
||||||
}: {
|
}: {
|
||||||
initialRecommendations: Recommendation[];
|
initialRecommendations: Recommendation[];
|
||||||
}) {
|
}) {
|
||||||
const [recommendations, setRecommendations] = useState(initialRecommendations);
|
const [recommendations, setRecommendations] = useState(
|
||||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
initialRecommendations,
|
||||||
|
);
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleAction = async (id: string, status: "approved" | "rejected") => {
|
const handleAction = async (id: string, status: "approved" | "rejected") => {
|
||||||
setProcessingId(id);
|
setProcessingId(id);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/recommendations/approve", {
|
const res = await fetch("/api/recommendations/approve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
recommendationId: id,
|
recommendationId: id,
|
||||||
status,
|
status,
|
||||||
approvedBy: "admin_placeholder", // In real app, get from auth context
|
approvedBy: "admin_placeholder", // In real app, get from auth context
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
alert("Failed to update status");
|
toast.error("Failed to update status");
|
||||||
} else {
|
} else {
|
||||||
setRecommendations((prev) =>
|
toast.success(
|
||||||
prev.filter((item) => item.recommendation.id !== id)
|
`Recommendation ${status === "approved" ? "approved" : "rejected"}`,
|
||||||
);
|
);
|
||||||
}
|
setRecommendations((prev) =>
|
||||||
} catch (error) {
|
prev.filter((item) => item.recommendation.id !== id),
|
||||||
console.error(error);
|
);
|
||||||
alert("Error processing request");
|
}
|
||||||
} finally {
|
} catch (error) {
|
||||||
setProcessingId(null);
|
console.error(error);
|
||||||
}
|
toast.error("Error processing request");
|
||||||
};
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
if (recommendations.length === 0) {
|
|
||||||
return <p className="text-gray-500 italic">No pending recommendations.</p>;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
if (recommendations.length === 0) {
|
||||||
<ul className="space-y-6">
|
return <p className="text-gray-500 italic">No pending recommendations.</p>;
|
||||||
{recommendations.map(({ recommendation, user }) => (
|
}
|
||||||
<li key={recommendation.id} className="border rounded p-4">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
return (
|
||||||
<h3 className="font-bold">
|
<ul className="space-y-6">
|
||||||
For: {user.firstName} {user.lastName}
|
{recommendations.map(({ recommendation, user }) => (
|
||||||
</h3>
|
<li key={recommendation.id} className="border rounded p-4">
|
||||||
<span className="text-xs text-gray-500">
|
<div className="flex justify-between items-start mb-2">
|
||||||
{new Date(recommendation.createdAt).toLocaleDateString()}
|
<h3 className="font-bold">
|
||||||
</span>
|
For: {user.firstName} {user.lastName}
|
||||||
</div>
|
</h3>
|
||||||
<div className="space-y-2 text-sm mb-4">
|
<span className="text-xs text-gray-500">
|
||||||
<div>
|
{new Date(recommendation.createdAt).toLocaleDateString()}
|
||||||
<span className="font-semibold">Advice:</span>{" "}
|
</span>
|
||||||
{recommendation.recommendationText}
|
</div>
|
||||||
</div>
|
<div className="space-y-2 text-sm mb-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Activity:</span>{" "}
|
<span className="font-semibold">Advice:</span>{" "}
|
||||||
{recommendation.activityPlan}
|
{recommendation.recommendationText}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Diet:</span> {recommendation.dietPlan}
|
<span className="font-semibold">Activity:</span>{" "}
|
||||||
</div>
|
{recommendation.activityPlan}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div>
|
||||||
<button
|
<span className="font-semibold">Diet:</span>{" "}
|
||||||
onClick={() => handleAction(recommendation.id, "approved")}
|
{recommendation.dietPlan}
|
||||||
disabled={processingId === recommendation.id}
|
</div>
|
||||||
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 flex justify-center items-center"
|
</div>
|
||||||
>
|
<div className="flex space-x-2">
|
||||||
{processingId === recommendation.id ? (
|
<button
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
onClick={() => handleAction(recommendation.id, "approved")}
|
||||||
) : (
|
disabled={processingId === recommendation.id}
|
||||||
<>
|
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 flex justify-center items-center"
|
||||||
<Check className="mr-2 h-4 w-4" /> Approve
|
>
|
||||||
</>
|
{processingId === recommendation.id ? (
|
||||||
)}
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</button>
|
) : (
|
||||||
<button
|
<>
|
||||||
onClick={() => handleAction(recommendation.id, "rejected")}
|
<Check className="mr-2 h-4 w-4" /> Approve
|
||||||
disabled={processingId === recommendation.id}
|
</>
|
||||||
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700 disabled:opacity-50 flex justify-center items-center"
|
)}
|
||||||
>
|
</button>
|
||||||
{processingId === recommendation.id ? (
|
<button
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
onClick={() => handleAction(recommendation.id, "rejected")}
|
||||||
) : (
|
disabled={processingId === recommendation.id}
|
||||||
<>
|
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700 disabled:opacity-50 flex justify-center items-center"
|
||||||
<X className="mr-2 h-4 w-4" /> Reject
|
>
|
||||||
</>
|
{processingId === recommendation.id ? (
|
||||||
)}
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</button>
|
) : (
|
||||||
</div>
|
<>
|
||||||
</li>
|
<X className="mr-2 h-4 w-4" /> Reject
|
||||||
))}
|
</>
|
||||||
</ul>
|
)}
|
||||||
);
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
103
apps/admin/src/components/ui/dialog.tsx
Normal file
103
apps/admin/src/components/ui/dialog.tsx
Normal file
@ -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<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className = "", ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={`fixed inset-0 z-50 bg-black/50 backdrop-blur-sm ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className = "", children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-gray-200 bg-white p-6 shadow-lg duration-200 sm:rounded-lg ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col space-y-1.5 text-center sm:text-left ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className = "", ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={`text-lg font-semibold leading-none tracking-tight ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className = "", ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={`text-sm text-gray-500 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
477
apps/admin/src/components/users/CreateUserModal.tsx
Normal file
477
apps/admin/src/components/users/CreateUserModal.tsx
Normal file
@ -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<Step>(1);
|
||||||
|
const [roleData, setRoleData] = useState<RoleSelectionData | null>(null);
|
||||||
|
const [basicInfo, setBasicInfo] = useState<BasicInfoData | null>(null);
|
||||||
|
const [clientInfo, setClientInfo] = useState<ClientInfoData | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Step 1: Role Selection Form
|
||||||
|
const roleForm = useForm<RoleSelectionData>({
|
||||||
|
resolver: zodResolver(roleSelectionSchema),
|
||||||
|
defaultValues: { role: "client" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Basic Info Form
|
||||||
|
const basicForm = useForm<BasicInfoData>({
|
||||||
|
resolver: zodResolver(basicInfoSchema),
|
||||||
|
defaultValues: {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Client Info Form
|
||||||
|
const clientForm = useForm<ClientInfoData>({
|
||||||
|
resolver: zodResolver(clientInfoSchema),
|
||||||
|
defaultValues: {
|
||||||
|
membershipType: "basic",
|
||||||
|
membershipStatus: "active",
|
||||||
|
sendWelcomeEmail: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Invitation Options Form
|
||||||
|
const invitationForm = useForm<InvitationOptionsData>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{getStepTitle()}</DialogTitle>
|
||||||
|
<DialogDescription>{getStepDescription()}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Step Progress Indicator */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{[1, 2, 3, 4].map((step) => (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
|
||||||
|
step < currentStep
|
||||||
|
? "bg-green-500 text-white"
|
||||||
|
: step === currentStep
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step < currentStep ? <Check className="w-4 h-4" /> : step}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Role Selection */}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<form
|
||||||
|
onSubmit={roleForm.handleSubmit(handleRoleNext)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Role *</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{(["admin", "trainer", "client"] as const).map((role) => (
|
||||||
|
<label
|
||||||
|
key={role}
|
||||||
|
className={`flex items-center justify-center p-4 border-2 rounded-lg cursor-pointer transition-colors ${
|
||||||
|
roleForm.watch("role") === role
|
||||||
|
? "border-blue-500 bg-blue-50"
|
||||||
|
: "border-gray-200 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value={role}
|
||||||
|
{...roleForm.register("role")}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium capitalize">
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{roleForm.formState.errors.role && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{roleForm.formState.errors.role.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Next <ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Basic Information */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<form
|
||||||
|
onSubmit={basicForm.handleSubmit(handleBasicNext)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">First Name *</label>
|
||||||
|
<Input {...basicForm.register("firstName")} placeholder="John" />
|
||||||
|
{basicForm.formState.errors.firstName && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{basicForm.formState.errors.firstName.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Last Name *</label>
|
||||||
|
<Input {...basicForm.register("lastName")} placeholder="Doe" />
|
||||||
|
{basicForm.formState.errors.lastName && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{basicForm.formState.errors.lastName.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Phone (Optional)</label>
|
||||||
|
<Input
|
||||||
|
{...basicForm.register("phone")}
|
||||||
|
placeholder="+1234567890"
|
||||||
|
type="tel"
|
||||||
|
/>
|
||||||
|
{basicForm.formState.errors.phone && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{basicForm.formState.errors.phone.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Email *</label>
|
||||||
|
<Input
|
||||||
|
{...basicForm.register("email")}
|
||||||
|
placeholder="john.doe@example.com"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Email is currently required in the database schema
|
||||||
|
</p>
|
||||||
|
{basicForm.formState.errors.email && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{basicForm.formState.errors.email.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex justify-between">
|
||||||
|
<Button type="button" variant="outline" onClick={handleBack}>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
Next <ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Client Information (only for clients) */}
|
||||||
|
{currentStep === 3 && roleData?.role === "client" && (
|
||||||
|
<form
|
||||||
|
onSubmit={clientForm.handleSubmit(handleClientNext)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Membership Type *</label>
|
||||||
|
<select
|
||||||
|
{...clientForm.register("membershipType")}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md"
|
||||||
|
>
|
||||||
|
<option value="basic">Basic</option>
|
||||||
|
<option value="premium">Premium</option>
|
||||||
|
<option value="vip">VIP</option>
|
||||||
|
</select>
|
||||||
|
{clientForm.formState.errors.membershipType && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{clientForm.formState.errors.membershipType.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Status *</label>
|
||||||
|
<select
|
||||||
|
{...clientForm.register("membershipStatus")}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md"
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{basicInfo?.email && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="sendWelcome"
|
||||||
|
{...clientForm.register("sendWelcomeEmail")}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<label htmlFor="sendWelcome" className="text-sm">
|
||||||
|
Send welcome email to {basicInfo.email}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="flex justify-between">
|
||||||
|
<Button type="button" variant="outline" onClick={handleBack}>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{basicInfo?.email ? (
|
||||||
|
<>
|
||||||
|
Next <ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Create User</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Invitation Options */}
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<form
|
||||||
|
onSubmit={invitationForm.handleSubmit(handleInvitationSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{basicInfo?.email && (
|
||||||
|
<>
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
Email provided: <strong>{basicInfo.email}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="sendInvitation"
|
||||||
|
{...invitationForm.register("sendInvitation")}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<label htmlFor="sendInvitation" className="text-sm">
|
||||||
|
Send Clerk invitation email (user must accept to create
|
||||||
|
account)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{invitationForm.watch("sendInvitation")
|
||||||
|
? "User will receive an email to create their account"
|
||||||
|
: "User record will be created without a Clerk account"}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!basicInfo?.email && (
|
||||||
|
<div className="p-4 bg-amber-50 border border-amber-200 rounded-md">
|
||||||
|
<p className="text-sm text-amber-900">
|
||||||
|
No email provided. User will be created in the database only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="flex justify-between">
|
||||||
|
<Button type="button" variant="outline" onClick={handleBack}>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Creating..." : "Create User"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
import log from "@/lib/logger";
|
import log from "@/lib/logger";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
interface Recommendation {
|
interface Recommendation {
|
||||||
id: string;
|
id: string;
|
||||||
@ -63,8 +64,9 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setNewRec({ ...newRec, content: "" });
|
setNewRec({ ...newRec, content: "" });
|
||||||
fetchRecommendations();
|
fetchRecommendations();
|
||||||
|
toast.success("Recommendation added successfully");
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to add recommendation");
|
toast.error("Failed to add recommendation");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to add recommendation", error);
|
log.error("Failed to add recommendation", error);
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useUser } from "@clerk/nextjs";
|
import { useUser } from "@clerk/nextjs";
|
||||||
import { getGymIdFromUser } from "@/lib/error-helpers";
|
import { getGymIdFromUser } from "@/lib/error-helpers";
|
||||||
import log from "@/lib/logger";
|
import log from "@/lib/logger";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { CreateUserModal } from "./CreateUserModal";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -40,6 +42,7 @@ export function UserManagement() {
|
|||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [editForm, setEditForm] = useState<{
|
const [editForm, setEditForm] = useState<{
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
@ -148,8 +151,9 @@ export function UserManagement() {
|
|||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
toast.success("Users deleted successfully");
|
||||||
} else {
|
} else {
|
||||||
alert("Error deleting users");
|
toast.error("Error deleting users");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to delete users", error);
|
log.error("Failed to delete users", error);
|
||||||
@ -255,12 +259,13 @@ export function UserManagement() {
|
|||||||
// Still re-fetch from server to ensure consistency
|
// Still re-fetch from server to ensure consistency
|
||||||
log.debug("Re-fetching users after successful edit");
|
log.debug("Re-fetching users after successful edit");
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
toast.success("User updated successfully");
|
||||||
} else {
|
} else {
|
||||||
const errText = await response.text().catch(() => "");
|
const errText = await response.text().catch(() => "");
|
||||||
log.error("User update failed", new Error(errText), {
|
log.error("User update failed", new Error(errText), {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
alert("Error updating user");
|
toast.error("Error updating user");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create (Invite) new user
|
// Create (Invite) new user
|
||||||
@ -278,15 +283,15 @@ export function UserManagement() {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setEditForm(null);
|
setEditForm(null);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
alert("Invitation sent successfully!");
|
toast.success("Invitation sent successfully!");
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
alert(`Error sending invitation: ${errorData.error}`);
|
toast.error(`Error sending invitation: ${errorData.error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert("An unexpected error occurred");
|
toast.error("An unexpected error occurred");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -301,8 +306,9 @@ export function UserManagement() {
|
|||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
toast.success("User deleted successfully");
|
||||||
} else {
|
} else {
|
||||||
alert("Error deleting user");
|
toast.error("Error deleting user");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to delete user", error);
|
log.error("Failed to delete user", error);
|
||||||
@ -327,22 +333,8 @@ export function UserManagement() {
|
|||||||
>
|
>
|
||||||
Edit User
|
Edit User
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="default" onClick={() => setCreateModalOpen(true)}>
|
||||||
variant={filter === "client" ? "default" : "outline"}
|
Create User
|
||||||
onClick={() => {
|
|
||||||
setEditForm({
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
email: "",
|
|
||||||
role: "client",
|
|
||||||
phone: "",
|
|
||||||
gymId: user ? getGymIdFromUser(user) : "",
|
|
||||||
});
|
|
||||||
setSelectedUser(null);
|
|
||||||
setIsEditing(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Invite User
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={filter === "client" ? "default" : "outline"}
|
variant={filter === "client" ? "default" : "outline"}
|
||||||
@ -657,6 +649,12 @@ export function UserManagement() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CreateUserModal
|
||||||
|
open={createModalOpen}
|
||||||
|
onOpenChange={setCreateModalOpen}
|
||||||
|
onSuccess={() => fetchUsers()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
99
apps/admin/src/emails/recommendation-email.tsx
Normal file
99
apps/admin/src/emails/recommendation-email.tsx
Normal file
@ -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 (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>New fitness recommendation from your trainer</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Heading style={h1}>New Recommendation! 📋</Heading>
|
||||||
|
<Text style={text}>Hi {clientName},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Your trainer has created a new personalized recommendation for you:
|
||||||
|
</Text>
|
||||||
|
<Section style={section}>
|
||||||
|
<Text style={recommendationContent}>{recommendationText}</Text>
|
||||||
|
</Section>
|
||||||
|
<Text style={text}>
|
||||||
|
Open your FitAI mobile app to view the complete recommendation
|
||||||
|
including your activity plan and nutrition guidance.
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Keep up the great work!
|
||||||
|
<br />
|
||||||
|
The FitAI Team
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
117
apps/admin/src/emails/trainer-assignment-email.tsx
Normal file
117
apps/admin/src/emails/trainer-assignment-email.tsx
Normal file
@ -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 (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>You've been assigned a trainer - {trainerName}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Heading style={h1}>Great News! 🎯</Heading>
|
||||||
|
<Text style={text}>Hi {clientName},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
You've been assigned a personal trainer to help you reach your
|
||||||
|
fitness goals!
|
||||||
|
</Text>
|
||||||
|
<Section style={section}>
|
||||||
|
<Heading style={h2}>Your Trainer</Heading>
|
||||||
|
<Text style={highlightText}>{trainerName}</Text>
|
||||||
|
</Section>
|
||||||
|
<Text style={text}>
|
||||||
|
Your trainer will work with you to:
|
||||||
|
<br />
|
||||||
|
• Create personalized workout plans
|
||||||
|
<br />
|
||||||
|
• Provide nutrition guidance
|
||||||
|
<br />
|
||||||
|
• Track your progress and achievements
|
||||||
|
<br />• Adjust your program as you improve
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Check your FitAI mobile app to view your trainer's profile and start
|
||||||
|
receiving personalized recommendations.
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Let's crush those goals together!
|
||||||
|
<br />
|
||||||
|
The FitAI Team
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
};
|
||||||
133
apps/admin/src/emails/user-invitation-email.tsx
Normal file
133
apps/admin/src/emails/user-invitation-email.tsx
Normal file
@ -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 (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>You've been invited to join FitAI as a {roleDisplay}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Heading style={h1}>Welcome to FitAI! 🎉</Heading>
|
||||||
|
<Text style={text}>
|
||||||
|
You've been invited to join FitAI as a{" "}
|
||||||
|
<strong>{roleDisplay}</strong>.
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Click the button below to accept your invitation and create your
|
||||||
|
account:
|
||||||
|
</Text>
|
||||||
|
<Section style={buttonContainer}>
|
||||||
|
<Button style={button} href={inviteUrl}>
|
||||||
|
Accept Invitation
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
<Text style={text}>
|
||||||
|
Or copy and paste this URL into your browser:
|
||||||
|
</Text>
|
||||||
|
<Text style={link}>
|
||||||
|
<Link href={inviteUrl}>{inviteUrl}</Link>
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
This invitation was sent to {email}. If you didn't expect this
|
||||||
|
email, you can safely ignore it.
|
||||||
|
</Text>
|
||||||
|
<Text style={footer}>The FitAI Team</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
};
|
||||||
101
apps/admin/src/emails/welcome-email.tsx
Normal file
101
apps/admin/src/emails/welcome-email.tsx
Normal file
@ -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 (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>Welcome to FitAI - Your fitness journey starts here!</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Heading style={h1}>Welcome to FitAI! 💪</Heading>
|
||||||
|
<Text style={text}>Hi {name},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
We're excited to have you join FitAI! Your account has been
|
||||||
|
successfully created, and you're all set to begin your fitness
|
||||||
|
journey.
|
||||||
|
</Text>
|
||||||
|
<Section style={section}>
|
||||||
|
<Heading style={h2}>Getting Started</Heading>
|
||||||
|
<Text style={text}>
|
||||||
|
• Download the FitAI mobile app to track your workouts
|
||||||
|
<br />
|
||||||
|
• Complete your fitness profile for personalized recommendations
|
||||||
|
<br />
|
||||||
|
• Check in at the gym to start tracking your attendance
|
||||||
|
<br />• Connect with your trainer for custom workout plans
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Text style={text}>
|
||||||
|
If you have any questions, feel free to reach out to your gym
|
||||||
|
administrator or trainer.
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Let's make fitness a lifestyle!
|
||||||
|
<br />
|
||||||
|
The FitAI Team
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
};
|
||||||
148
apps/admin/src/lib/email.ts
Normal file
148
apps/admin/src/lib/email.ts
Normal file
@ -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 <noreply@fitai.com>")
|
||||||
|
* - 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 <noreply@fitai.com>";
|
||||||
|
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: <WelcomeEmail name="John" />
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
77
apps/admin/src/lib/toast.ts
Normal file
77
apps/admin/src/lib/toast.ts
Normal file
@ -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: <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
111
apps/admin/src/lib/validation/user-schemas.ts
Normal file
111
apps/admin/src/lib/validation/user-schemas.ts
Normal file
@ -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<typeof roleSelectionSchema>;
|
||||||
|
|
||||||
|
// 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<typeof basicInfoSchema>;
|
||||||
|
|
||||||
|
// 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<typeof clientInfoSchema>;
|
||||||
|
|
||||||
|
// 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<typeof invitationOptionsSchema>;
|
||||||
|
|
||||||
|
// 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<typeof createUserSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@ -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
|
# Clerk Authentication
|
||||||
# Get these values from https://dashboard.clerk.com
|
# =============================================================================
|
||||||
# Make sure to use the correct publishable key for your environment
|
# Get your publishable key from https://dashboard.clerk.com
|
||||||
# IMPORTANT: Must start with EXPO_PUBLIC_ to be available in the app
|
# 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
|
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# API Configuration
|
# API Configuration
|
||||||
# Development: Use ngrok or local network IP (e.g., http://192.168.1.100:3000)
|
# =============================================================================
|
||||||
# Production: Use your production API URL
|
# Backend API base URL (without /api suffix)
|
||||||
EXPO_PUBLIC_API_URL=http://localhost:3000/api
|
#
|
||||||
|
# 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_NAME=FitAI
|
||||||
EXPO_PUBLIC_APP_VERSION=1.0.0
|
EXPO_PUBLIC_APP_VERSION=1.0.0
|
||||||
|
|||||||
@ -5,47 +5,57 @@ import log from "../utils/logger";
|
|||||||
|
|
||||||
export interface FitnessProfile {
|
export interface FitnessProfile {
|
||||||
id?: string;
|
id?: string;
|
||||||
clientId: string;
|
userId: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
goals?: string;
|
age?: number;
|
||||||
fitnessLevel: "beginner" | "intermediate" | "advanced";
|
gender?: "male" | "female" | "other" | "prefer_not_to_say";
|
||||||
|
fitnessGoals?: string[];
|
||||||
|
activityLevel?:
|
||||||
|
| "sedentary"
|
||||||
|
| "lightly_active"
|
||||||
|
| "moderately_active"
|
||||||
|
| "very_active"
|
||||||
|
| "extremely_active";
|
||||||
medicalConditions?: string;
|
medicalConditions?: string;
|
||||||
dietaryRestrictions?: string;
|
allergies?: string;
|
||||||
preferredWorkoutTime?: "morning" | "afternoon" | "evening";
|
injuries?: string;
|
||||||
workoutFrequency?: number;
|
preferences?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fitnessProfileApi = {
|
export const fitnessProfileApi = {
|
||||||
getFitnessProfile: async (
|
getFitnessProfile: async (token: string): Promise<FitnessProfile | null> => {
|
||||||
userId: string,
|
|
||||||
token: string,
|
|
||||||
): Promise<FitnessProfile> => {
|
|
||||||
try {
|
try {
|
||||||
log.debug("Getting fitness profile", { userId });
|
log.debug("Getting fitness profile");
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(`${API_ENDPOINTS.PROFILE.FITNESS}`, {
|
||||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
headers: {
|
||||||
{
|
Authorization: `Bearer ${token}`,
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
return response.data;
|
return response.data.profile || null;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
log.error("Failed to fetch fitness profile", error);
|
log.error("Failed to fetch fitness profile", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
checkProfileExists: async (token: string): Promise<boolean> => {
|
||||||
|
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 (
|
updateFitnessProfile: async (
|
||||||
userId: string,
|
|
||||||
data: Partial<FitnessProfile>,
|
data: Partial<FitnessProfile>,
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<FitnessProfile> => {
|
): Promise<FitnessProfile> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(
|
const response = await apiClient.put(
|
||||||
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
|
`${API_ENDPOINTS.PROFILE.FITNESS}`,
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@ -66,7 +76,7 @@ export const fitnessProfileApi = {
|
|||||||
): Promise<FitnessProfile> => {
|
): Promise<FitnessProfile> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`${API_ENDPOINTS.USERS}/${data.clientId}/fitness-profile`,
|
`${API_ENDPOINTS.PROFILE.FITNESS}`,
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
||||||
|
import { API_BASE_URL } from "@/config/api";
|
||||||
import log from "../../utils/logger";
|
import log from "../../utils/logger";
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
@ -30,7 +31,7 @@ export default function OnboardingScreen() {
|
|||||||
try {
|
try {
|
||||||
setGymsLoading(true);
|
setGymsLoading(true);
|
||||||
const token = await getToken();
|
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,
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -48,6 +49,7 @@ export default function OnboardingScreen() {
|
|||||||
const [fitnessProfile, setFitnessProfile] = useState({
|
const [fitnessProfile, setFitnessProfile] = useState({
|
||||||
height: "",
|
height: "",
|
||||||
weight: "",
|
weight: "",
|
||||||
|
age: "",
|
||||||
goals: "",
|
goals: "",
|
||||||
fitnessLevel: "beginner",
|
fitnessLevel: "beginner",
|
||||||
medicalConditions: "",
|
medicalConditions: "",
|
||||||
@ -63,8 +65,12 @@ export default function OnboardingScreen() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!fitnessProfile.height || !fitnessProfile.weight) {
|
if (
|
||||||
Alert.alert("Error", "Please enter your height and weight");
|
!fitnessProfile.height ||
|
||||||
|
!fitnessProfile.weight ||
|
||||||
|
!fitnessProfile.age
|
||||||
|
) {
|
||||||
|
Alert.alert("Error", "Please enter your height, weight, and age");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +82,7 @@ export default function OnboardingScreen() {
|
|||||||
// If gym was selected or cleared, patch user's gym selection first
|
// If gym was selected or cleared, patch user's gym selection first
|
||||||
// selectedGymId: string gym id, or null to proceed without gym
|
// selectedGymId: string gym id, or null to proceed without gym
|
||||||
try {
|
try {
|
||||||
await fetch("/api/users/gym", {
|
await fetch(`${API_BASE_URL}/api/users/gym`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -89,21 +95,13 @@ export default function OnboardingScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fitnessData = {
|
const fitnessData = {
|
||||||
clientId: user.id,
|
userId: user.id,
|
||||||
height: parseFloat(fitnessProfile.height),
|
height: parseFloat(fitnessProfile.height),
|
||||||
weight: parseFloat(fitnessProfile.weight),
|
weight: parseFloat(fitnessProfile.weight),
|
||||||
goals: fitnessProfile.goals || undefined,
|
age: parseInt(fitnessProfile.age),
|
||||||
fitnessLevel: fitnessProfile.fitnessLevel as
|
fitnessGoals: fitnessProfile.goals ? [fitnessProfile.goals] : [],
|
||||||
| "beginner"
|
|
||||||
| "intermediate"
|
|
||||||
| "advanced",
|
|
||||||
medicalConditions: fitnessProfile.medicalConditions || undefined,
|
medicalConditions: fitnessProfile.medicalConditions || undefined,
|
||||||
dietaryRestrictions: fitnessProfile.dietaryRestrictions || undefined,
|
allergies: fitnessProfile.dietaryRestrictions || undefined,
|
||||||
preferredWorkoutTime: fitnessProfile.preferredWorkoutTime as
|
|
||||||
| "morning"
|
|
||||||
| "afternoon"
|
|
||||||
| "evening",
|
|
||||||
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
||||||
@ -127,6 +125,24 @@ export default function OnboardingScreen() {
|
|||||||
setFitnessProfile({ ...fitnessProfile, preferredWorkoutTime: time });
|
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 (
|
return (
|
||||||
<ScrollView style={styles.container}>
|
<ScrollView style={styles.container}>
|
||||||
<Text style={styles.title}>Set Up Your Fitness Profile</Text>
|
<Text style={styles.title}>Set Up Your Fitness Profile</Text>
|
||||||
@ -134,6 +150,14 @@ export default function OnboardingScreen() {
|
|||||||
Help us personalize your fitness journey
|
Help us personalize your fitness journey
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<View style={styles.progressBarBackground}>
|
||||||
|
<View style={[styles.progressBarFill, { width: `${progress}%` }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.progressText}>{progress}% Complete</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
<Text style={styles.label}>Height (cm)</Text>
|
<Text style={styles.label}>Height (cm)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -157,6 +181,17 @@ export default function OnboardingScreen() {
|
|||||||
placeholder="Enter weight in kg"
|
placeholder="Enter weight in kg"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Age</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={fitnessProfile.age}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
setFitnessProfile({ ...fitnessProfile, age: value })
|
||||||
|
}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="Enter your age"
|
||||||
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Fitness Goals</Text>
|
<Text style={styles.label}>Fitness Goals</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, styles.textArea]}
|
style={[styles.input, styles.textArea]}
|
||||||
@ -409,4 +444,25 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
|
progressContainer: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
progressBarBackground: {
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: "#e5e7eb",
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
progressBarFill: {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#3b82f6",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6b7280",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export default function SignUpScreen() {
|
|||||||
// Redirect if already signed in
|
// Redirect if already signed in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSignedIn) {
|
if (isSignedIn) {
|
||||||
router.replace("/(tabs)");
|
router.replace("/(auth)/onboarding");
|
||||||
}
|
}
|
||||||
}, [isSignedIn]);
|
}, [isSignedIn]);
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ export default function SignUpScreen() {
|
|||||||
|
|
||||||
if (completeSignUp.status === "complete") {
|
if (completeSignUp.status === "complete") {
|
||||||
await setActive({ session: completeSignUp.createdSessionId });
|
await setActive({ session: completeSignUp.createdSessionId });
|
||||||
router.replace("/(tabs)");
|
router.replace("/(auth)/onboarding");
|
||||||
} else {
|
} else {
|
||||||
log.warn("Verification incomplete", { status: completeSignUp.status });
|
log.warn("Verification incomplete", { status: completeSignUp.status });
|
||||||
setError("Verification incomplete. Please try again.");
|
setError("Verification incomplete. Please try again.");
|
||||||
@ -87,7 +87,7 @@ export default function SignUpScreen() {
|
|||||||
// Handle specific error codes
|
// Handle specific error codes
|
||||||
if (getClerkErrorCode(err) === "session_exists") {
|
if (getClerkErrorCode(err) === "session_exists") {
|
||||||
// User is already signed in, just redirect
|
// User is already signed in, just redirect
|
||||||
router.replace("/(tabs)");
|
router.replace("/(auth)/onboarding");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { Tabs, useRouter, useSegments } from "expo-router";
|
import { Tabs, useRouter, useSegments } from "expo-router";
|
||||||
import { useAuth } from "@clerk/clerk-expo";
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { CustomTabBar } from "../../components/CustomTabBar";
|
import { CustomTabBar } from "../../components/CustomTabBar";
|
||||||
|
import { fitnessProfileApi } from "../../api/fitnessProfile";
|
||||||
|
import log from "../../utils/logger";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { isSignedIn, isLoaded } = useAuth();
|
const { isSignedIn, isLoaded, getToken } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
const [onboardingChecked, setOnboardingChecked] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoaded) return;
|
if (!isLoaded) return;
|
||||||
@ -16,10 +19,40 @@ export default function TabLayout() {
|
|||||||
if (!isSignedIn && !inAuthGroup) {
|
if (!isSignedIn && !inAuthGroup) {
|
||||||
// Redirect to sign-in if not authenticated
|
// Redirect to sign-in if not authenticated
|
||||||
router.replace("/(auth)/sign-in");
|
router.replace("/(auth)/sign-in");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has completed onboarding
|
||||||
|
if (isSignedIn && !onboardingChecked) {
|
||||||
|
checkOnboardingStatus();
|
||||||
}
|
}
|
||||||
}, [isSignedIn, isLoaded, segments]);
|
}, [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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const API_ENDPOINTS = {
|
|||||||
REGISTER: "/api/auth/register",
|
REGISTER: "/api/auth/register",
|
||||||
},
|
},
|
||||||
PROFILE: {
|
PROFILE: {
|
||||||
FITNESS: "/api/profile/fitness",
|
FITNESS: "/api/fitness-profile",
|
||||||
},
|
},
|
||||||
CLIENTS: "/api/clients",
|
CLIENTS: "/api/clients",
|
||||||
USERS: "/api/users",
|
USERS: "/api/users",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user