clerkauth
implemented, sync with db to be added
This commit is contained in:
1 parent
ca790a7b97
commit
3a58d420d6
49 files changed
+9423
-779
No files matched your search
@@ -0,0 +1,14 @@
|
||||
# Clerk Authentication
|
||||
# Get these values from https://dashboard.clerk.com
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_bmVlZGVkLWVsZXBoYW50LTY0LmNsZXJrLmFjY291bnRzLmRldiQ
|
||||
CLERK_SECRET_KEY=sk_test_qnWnZSem1ZkodRip9NZDXszDnCP91HwlNwtAUAcHZ1
|
||||
|
||||
|
||||
# Clerk URLs (customize these for your app)
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
|
||||
|
||||
# Database
|
||||
DATABASE_URL=./data/fitai.db
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib",
|
||||
"ui": "@/components/ui"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Generated
+194
-3
@@ -8,6 +8,7 @@
|
||||
"name": "@fitai/admin",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^6.34.5",
|
||||
"@fitai/shared": "file:../../packages/shared",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
@@ -30,6 +31,7 @@
|
||||
"recharts": "^3.3.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"svix": "^1.81.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
@@ -600,6 +602,103 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@clerk/backend": {
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.20.0.tgz",
|
||||
"integrity": "sha512-RcZN7CAxGkkLydGtWpxCyq4C0pSo/1ch0LJMDQnckrt10Jx8mAjwce2nZQa2xRykxsOla4+boF9a5kDw3nUvVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "^3.31.1",
|
||||
"@clerk/types": "^4.97.2",
|
||||
"cookie": "1.0.2",
|
||||
"standardwebhooks": "^1.0.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/clerk-react": {
|
||||
"version": "5.53.8",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.53.8.tgz",
|
||||
"integrity": "sha512-TOiYk31rQUL9JOKZr/fhajf+fQCHicy1J4Rxq7vqtjHseJsnIBjzTigjOap/w8PrDAF28O6dbPC5CA0Tp7Md8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "^3.31.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/nextjs": {
|
||||
"version": "6.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.34.5.tgz",
|
||||
"integrity": "sha512-f1OyucHc5HHBZovzEtJrPR0MUePZxEH2mqu3dt24iGTWTmV2UPnHMB5uSi4XVSWcungnzHWKgTKnHKTVF3vxUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/backend": "^2.20.0",
|
||||
"@clerk/clerk-react": "^5.53.8",
|
||||
"@clerk/shared": "^3.31.1",
|
||||
"@clerk/types": "^4.97.2",
|
||||
"server-only": "0.0.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16",
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/shared": {
|
||||
"version": "3.31.1",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.31.1.tgz",
|
||||
"integrity": "sha512-mqxZqlzLJYJxA+ryLzhwFR0eO73teAvRd+wvA8bLUZLYvCRFvaiHsB9dEvbo9Z5bMYdq3NPwnx2uljMuu/tiQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "3.1.3",
|
||||
"dequal": "2.0.3",
|
||||
"glob-to-regexp": "0.4.1",
|
||||
"js-cookie": "3.0.5",
|
||||
"std-env": "^3.9.0",
|
||||
"swr": "2.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/types": {
|
||||
"version": "4.97.2",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.97.2.tgz",
|
||||
"integrity": "sha512-xnJq3xzpmuuDnNnWuUMKJLPPkaEaLDM0kiv2Hm0gKIcL1+1P3VaGf2vL9roIhmhLswB2PUwtVvZKBmGjT5yOVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "^3.31.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz",
|
||||
@@ -2203,6 +2302,12 @@
|
||||
"@sinonjs/commons": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
@@ -4607,6 +4712,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4633,7 +4747,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
@@ -4954,9 +5067,7 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -5933,6 +6044,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
@@ -6414,6 +6531,12 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-to-regexp": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -8333,6 +8456,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -10652,6 +10784,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/server-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
|
||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
@@ -11091,6 +11229,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -11433,6 +11587,30 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svix": {
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.81.0.tgz",
|
||||
"integrity": "sha512-Q4DiYb1ydhRYqez65vZES8AkGY2oxn26qP7mLVbMf8Orrveb54TZLkaVG5zr7eJT4T3zYRThkKf6aOnvzgwhYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz",
|
||||
"integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.11",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
|
||||
@@ -12000,6 +12178,19 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^6.34.5",
|
||||
"@fitai/shared": "file:../../packages/shared",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
@@ -33,6 +34,7 @@
|
||||
"recharts": "^3.3.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"svix": "^1.81.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { setUserRole, isAdmin, type UserRole } from '@/lib/clerk-helpers';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Authenticate the request
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if the requesting user is an admin
|
||||
const requestingUserIsAdmin = await isAdmin(userId);
|
||||
|
||||
if (!requestingUserIsAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: Admin access required' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await req.json();
|
||||
const { targetUserId, role } = body;
|
||||
|
||||
// Validate inputs
|
||||
if (!targetUserId || typeof targetUserId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing targetUserId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!role || !['admin', 'trainer', 'client'].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid role. Must be admin, trainer, or client' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent admin from changing their own role
|
||||
if (userId === targetUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot change your own role' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set the user's role
|
||||
const updatedUser = await setUserRole(targetUserId, role as UserRole);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `User role updated to ${role}`,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.emailAddresses[0]?.emailAddress,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
role: updatedUser.publicMetadata.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting user role:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Webhook } from 'svix';
|
||||
import { headers } from 'next/headers';
|
||||
import { WebhookEvent } from '@clerk/nextjs/server';
|
||||
import { db } from '@fitai/database';
|
||||
import { users } from '@fitai/database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// Get the webhook secret from environment variables
|
||||
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
|
||||
|
||||
if (!WEBHOOK_SECRET) {
|
||||
throw new Error('Please add CLERK_WEBHOOK_SECRET to your environment variables');
|
||||
}
|
||||
|
||||
// Get the headers
|
||||
const headerPayload = await headers();
|
||||
const svix_id = headerPayload.get('svix-id');
|
||||
const svix_timestamp = headerPayload.get('svix-timestamp');
|
||||
const svix_signature = headerPayload.get('svix-signature');
|
||||
|
||||
// If there are no headers, error out
|
||||
if (!svix_id || !svix_timestamp || !svix_signature) {
|
||||
return new Response('Error: Missing svix headers', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the body
|
||||
const payload = await req.json();
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
// Create a new Svix instance with your webhook secret
|
||||
const wh = new Webhook(WEBHOOK_SECRET);
|
||||
|
||||
let evt: WebhookEvent;
|
||||
|
||||
// Verify the webhook signature
|
||||
try {
|
||||
evt = wh.verify(body, {
|
||||
'svix-id': svix_id,
|
||||
'svix-timestamp': svix_timestamp,
|
||||
'svix-signature': svix_signature,
|
||||
}) as WebhookEvent;
|
||||
} catch (err) {
|
||||
console.error('Error verifying webhook:', err);
|
||||
return new Response('Error: Verification failed', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle the webhook
|
||||
const eventType = evt.type;
|
||||
console.log(`Received webhook with ID ${evt.data.id} and type ${eventType}`);
|
||||
|
||||
try {
|
||||
switch (eventType) {
|
||||
case 'user.created': {
|
||||
const { id, email_addresses, first_name, last_name, public_metadata } = evt.data;
|
||||
|
||||
// Get primary email
|
||||
const primaryEmail = email_addresses.find(
|
||||
(email) => email.id === evt.data.primary_email_address_id
|
||||
);
|
||||
|
||||
if (!primaryEmail?.email_address) {
|
||||
console.error('No primary email found for user:', id);
|
||||
return new Response('Error: No primary email', { status: 400 });
|
||||
}
|
||||
|
||||
// Determine role from metadata or default to 'client'
|
||||
const role = (public_metadata?.role as 'admin' | 'trainer' | 'client') || 'client';
|
||||
|
||||
// Insert user into database
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
email: primaryEmail.email_address,
|
||||
firstName: first_name || '',
|
||||
lastName: last_name || '',
|
||||
password: '', // Clerk handles authentication, no password needed
|
||||
role,
|
||||
phone: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(`✅ User ${id} created in database`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'user.updated': {
|
||||
const { id, email_addresses, first_name, last_name, public_metadata } = evt.data;
|
||||
|
||||
// Get primary email
|
||||
const primaryEmail = email_addresses.find(
|
||||
(email) => email.id === evt.data.primary_email_address_id
|
||||
);
|
||||
|
||||
if (!primaryEmail?.email_address) {
|
||||
console.error('No primary email found for user:', id);
|
||||
return new Response('Error: No primary email', { status: 400 });
|
||||
}
|
||||
|
||||
// Determine role from metadata
|
||||
const role = (public_metadata?.role as 'admin' | 'trainer' | 'client') || 'client';
|
||||
|
||||
// Update user in database
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
email: primaryEmail.email_address,
|
||||
firstName: first_name || '',
|
||||
lastName: last_name || '',
|
||||
role,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id));
|
||||
|
||||
console.log(`✅ User ${id} updated in database`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'user.deleted': {
|
||||
const { id } = evt.data;
|
||||
|
||||
if (!id) {
|
||||
console.error('No user ID provided for deletion');
|
||||
return new Response('Error: No user ID', { status: 400 });
|
||||
}
|
||||
|
||||
// Delete user from database (cascade will handle related records)
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
|
||||
console.log(`✅ User ${id} deleted from database`);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`Unhandled webhook event type: ${eventType}`);
|
||||
}
|
||||
|
||||
return new Response('Webhook processed successfully', { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Error processing webhook:', error);
|
||||
return new Response('Error: Processing failed', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,52 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import {
|
||||
ClerkProvider,
|
||||
SignedIn,
|
||||
SignedOut,
|
||||
SignInButton,
|
||||
UserButton,
|
||||
} from "@clerk/nextjs";
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FitAI Admin',
|
||||
description: 'Fitness management admin dashboard',
|
||||
}
|
||||
title: "FitAI Admin",
|
||||
description: "Fitness management admin dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
<ClerkProvider>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-gray-900">FitAI Admin</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<SignedIn>
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
</SignedIn>
|
||||
<SignedOut>
|
||||
<SignInButton mode="modal">
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
|
||||
Sign In
|
||||
</button>
|
||||
</SignInButton>
|
||||
</SignedOut>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface UserProfile {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, isLoaded } = useUser();
|
||||
const [profile, setProfile] = useState<UserProfile>({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
});
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded && user) {
|
||||
setProfile({
|
||||
firstName: user.firstName || "",
|
||||
lastName: user.lastName || "",
|
||||
email: user.emailAddresses[0]?.emailAddress || "",
|
||||
phoneNumber: user.phoneNumbers[0]?.phoneNumber || "",
|
||||
});
|
||||
}
|
||||
}, [isLoaded, user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await user?.update({
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Error updating profile:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoaded) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="max-w-2xl mx-auto bg-white p-8 rounded-lg shadow">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Profile</h1>
|
||||
<Button
|
||||
variant={isEditing ? "default" : "outline"}
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
>
|
||||
{isEditing ? "Cancel" : "Edit Profile"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.firstName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setProfile({ ...profile, firstName: e.target.value })
|
||||
}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last Name</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.lastName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setProfile({ ...profile, lastName: e.target.value })
|
||||
}
|
||||
disabled={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<Input type="email" value={profile.email} disabled />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Phone Number
|
||||
</label>
|
||||
<Input type="tel" value={profile.phoneNumber} disabled />
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="pt-4">
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Home, Users, BarChart3, User } from "lucide-react";
|
||||
import { SignedIn, UserButton } from "@clerk/nextjs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: "/",
|
||||
label: "Dashboard",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
href: "/users",
|
||||
label: "Clients",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
href: "/analytics",
|
||||
label: "Analytics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
href: "/profile",
|
||||
label: "Profile",
|
||||
icon: User,
|
||||
},
|
||||
];
|
||||
|
||||
export function Navigation(): ReactElement {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<nav className="container mx-auto px-4 h-16">
|
||||
<div className="flex h-full items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-[#FF0000] hover:text-[#00FF00] transition-colors"
|
||||
>
|
||||
FitAI
|
||||
</Link>
|
||||
|
||||
{/* Navigation Items */}
|
||||
<ul
|
||||
className="flex-1 flex justify-center items-center"
|
||||
style={{ gap: "3rem" }}
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Button
|
||||
asChild
|
||||
variant={pathname === item.href ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 px-4 py-2",
|
||||
pathname === item.href &&
|
||||
"bg-primary text-primary-foreground",
|
||||
)}
|
||||
aria-current={pathname === item.href ? "page" : undefined}
|
||||
>
|
||||
<Link href={item.href} className="flex items-center gap-2">
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* User Button */}
|
||||
<SignedIn>
|
||||
<UserButton
|
||||
afterSignOutUrl="/"
|
||||
appearance={{
|
||||
elements: {
|
||||
avatarBox: "h-8 w-8",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</SignedIn>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { clerkClient } from '@clerk/nextjs/server';
|
||||
|
||||
/**
|
||||
* User roles available in the application
|
||||
*/
|
||||
export type UserRole = 'admin' | 'trainer' | 'client';
|
||||
|
||||
/**
|
||||
* Set a user's role in Clerk public metadata
|
||||
* This will trigger a webhook that syncs the role to the database
|
||||
*
|
||||
* @param userId - Clerk user ID
|
||||
* @param role - Role to assign (admin, trainer, or client)
|
||||
* @returns Updated user object
|
||||
*
|
||||
* @example
|
||||
* await setUserRole('user_abc123', 'admin');
|
||||
*/
|
||||
export async function setUserRole(userId: string, role: UserRole) {
|
||||
const client = await clerkClient();
|
||||
|
||||
return await client.users.updateUser(userId, {
|
||||
publicMetadata: { role },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's role from Clerk public metadata
|
||||
*
|
||||
* @param userId - Clerk user ID
|
||||
* @returns User role or null if not set
|
||||
*
|
||||
* @example
|
||||
* const role = await getUserRole('user_abc123');
|
||||
* console.log(role); // 'admin' | 'trainer' | 'client' | null
|
||||
*/
|
||||
export async function getUserRole(userId: string): Promise<UserRole | null> {
|
||||
const client = await clerkClient();
|
||||
const user = await client.users.getUser(userId);
|
||||
|
||||
const role = user.publicMetadata?.role as UserRole | undefined;
|
||||
return role || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a specific role
|
||||
*
|
||||
* @param userId - Clerk user ID
|
||||
* @param role - Role to check
|
||||
* @returns True if user has the role
|
||||
*
|
||||
* @example
|
||||
* const isAdmin = await hasRole('user_abc123', 'admin');
|
||||
*/
|
||||
export async function hasRole(userId: string, role: UserRole): Promise<boolean> {
|
||||
const userRole = await getUserRole(userId);
|
||||
return userRole === role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is an admin
|
||||
*
|
||||
* @param userId - Clerk user ID
|
||||
* @returns True if user is an admin
|
||||
*
|
||||
* @example
|
||||
* const isAdmin = await isAdmin('user_abc123');
|
||||
*/
|
||||
export async function isAdmin(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a trainer
|
||||
*
|
||||
* @param userId - Clerk user ID
|
||||
* @returns True if user is a trainer
|
||||
*
|
||||
* @example
|
||||
* const isTrainer = await isTrainer('user_abc123');
|
||||
*/
|
||||
export async function isTrainer(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'trainer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a client
|
||||
*
|
||||
* @param userId - Clerk user ID
|
||||
* @returns True if user is a client
|
||||
*
|
||||
* @example
|
||||
* const isClient = await isClient('user_abc123');
|
||||
*/
|
||||
export async function isClient(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'client');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update roles for multiple users
|
||||
*
|
||||
* @param userRoles - Array of {userId, role} objects
|
||||
* @returns Array of updated users
|
||||
*
|
||||
* @example
|
||||
* await bulkSetUserRoles([
|
||||
* { userId: 'user_abc123', role: 'admin' },
|
||||
* { userId: 'user_def456', role: 'trainer' },
|
||||
* ]);
|
||||
*/
|
||||
export async function bulkSetUserRoles(
|
||||
userRoles: Array<{ userId: string; role: UserRole }>
|
||||
): Promise<void> {
|
||||
const client = await clerkClient();
|
||||
|
||||
await Promise.all(
|
||||
userRoles.map(({ userId, role }) =>
|
||||
client.users.updateUser(userId, {
|
||||
publicMetadata: { role },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users with a specific role
|
||||
*
|
||||
* @param role - Role to filter by
|
||||
* @returns Array of users with the specified role
|
||||
*
|
||||
* @example
|
||||
* const admins = await getUsersByRole('admin');
|
||||
*/
|
||||
export async function getUsersByRole(role: UserRole) {
|
||||
const client = await clerkClient();
|
||||
|
||||
// Clerk doesn't support filtering by metadata directly, so we fetch all users
|
||||
// and filter client-side. For large user bases, consider caching or database queries.
|
||||
const { data: users } = await client.users.getUserList();
|
||||
|
||||
return users.filter(
|
||||
(user) => (user.publicMetadata?.role as UserRole) === role
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user count by role
|
||||
*
|
||||
* @returns Object with counts for each role
|
||||
*
|
||||
* @example
|
||||
* const counts = await getUserCountByRole();
|
||||
* console.log(counts); // { admin: 2, trainer: 5, client: 100 }
|
||||
*/
|
||||
export async function getUserCountByRole(): Promise<Record<UserRole, number>> {
|
||||
const client = await clerkClient();
|
||||
const { data: users } = await client.users.getUserList();
|
||||
|
||||
const counts: Record<UserRole, number> = {
|
||||
admin: 0,
|
||||
trainer: 0,
|
||||
client: 0,
|
||||
};
|
||||
|
||||
users.forEach((user) => {
|
||||
const role = (user.publicMetadata?.role as UserRole) || 'client';
|
||||
counts[role]++;
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync user role between Clerk and database manually
|
||||
* Useful for fixing inconsistencies or after manual changes
|
||||
*
|
||||
* @param userId - Clerk user ID
|
||||
* @returns Success status
|
||||
*
|
||||
* @example
|
||||
* await syncUserRole('user_abc123');
|
||||
*/
|
||||
export async function syncUserRole(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const client = await clerkClient();
|
||||
const user = await client.users.getUser(userId);
|
||||
|
||||
// Trigger a webhook by updating the user (even with same data)
|
||||
await client.users.updateUser(userId, {
|
||||
publicMetadata: user.publicMetadata,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error syncing user role:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
|
||||
// Define routes that should be publicly accessible
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
"/sign-in(.*)",
|
||||
"/sign-up(.*)",
|
||||
"/api/webhooks(.*)",
|
||||
]);
|
||||
|
||||
// Define routes that require authentication
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
"/",
|
||||
"/users(.*)",
|
||||
"/analytics(.*)",
|
||||
"/profile(.*)",
|
||||
"/api/users(.*)",
|
||||
"/api/profile(.*)",
|
||||
"/api/payments(.*)",
|
||||
"/api/attendance(.*)",
|
||||
"/api/notifications(.*)",
|
||||
]);
|
||||
|
||||
export default clerkMiddleware(async (auth, req) => {
|
||||
// Protect all routes except public ones
|
||||
if (!isPublicRoute(req)) {
|
||||
await auth.protect();
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip Next.js internals and all static files, unless found in search params
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
// Always run for API routes
|
||||
"/(api|trpc)(.*)",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
# Clerk Authentication
|
||||
# Get these values from https://dashboard.clerk.com
|
||||
# Make sure to use the correct publishable key for your environment
|
||||
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cGxlYXNpbmctcGhlYXNhbnQtMjAuY2xlcmsuYWNjb3VudHMuZGV2JA
|
||||
|
||||
# API Configuration
|
||||
# Update this to point to your backend API
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000/api
|
||||
|
||||
# App Configuration
|
||||
EXPO_PUBLIC_APP_NAME=FitAI
|
||||
EXPO_PUBLIC_APP_VERSION=1.0.0
|
||||
@@ -0,0 +1,11 @@
|
||||
# npm configuration for FitAI mobile app
|
||||
# This ensures consistent dependency resolution across the team
|
||||
|
||||
# Use legacy peer deps to resolve React Native dependency conflicts
|
||||
legacy-peer-deps=true
|
||||
|
||||
# Prefer offline mode for faster installs (optional)
|
||||
# prefer-offline=true
|
||||
|
||||
# Save exact versions to package.json
|
||||
# save-exact=true
|
||||
Generated
+1290
-10
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/clerk-expo": "^2.18.3",
|
||||
"@expo/vector-icons": "^15.0.0",
|
||||
"@hookform/resolvers": "^3.3.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
@@ -21,29 +22,36 @@
|
||||
"ajv-keywords": "^5.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.23",
|
||||
"expo-auth-session": "^7.0.8",
|
||||
"expo-camera": "~17.0.0",
|
||||
"expo-constants": "^18.0.10",
|
||||
"expo-crypto": "^15.0.7",
|
||||
"expo-linking": "~8.0.0",
|
||||
"expo-notifications": "~0.32.0",
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-status-bar": "^3.0.8",
|
||||
"expo-web-browser": "^15.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@testing-library/react-native": "^12.4.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"typescript": "^5.1.3",
|
||||
"eslint": "^8.45.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"babel-preset-expo": "~54.0.7",
|
||||
"eslint": "^8.45.0",
|
||||
"jest": "^29.2.1",
|
||||
"@testing-library/react-native": "^12.4.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"babel-preset-expo": "~54.0.7"
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import axios from "axios";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const API_BASE_URL = Platform.select({
|
||||
android: "http://10.0.2.2:3000", // Android emulator maps this to host's localhost
|
||||
ios: "http://localhost:3000",
|
||||
default: process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000",
|
||||
});
|
||||
|
||||
export interface FitnessProfile {
|
||||
id?: string;
|
||||
clientId: string;
|
||||
height?: number;
|
||||
weight?: number;
|
||||
goals?: string;
|
||||
fitnessLevel: "beginner" | "intermediate" | "advanced";
|
||||
medicalConditions?: string;
|
||||
dietaryRestrictions?: string;
|
||||
preferredWorkoutTime?: "morning" | "afternoon" | "evening";
|
||||
workoutFrequency?: number;
|
||||
}
|
||||
|
||||
export const fitnessProfileApi = {
|
||||
getFitnessProfile: async (
|
||||
userId: string,
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
console.log(
|
||||
"Getting fitness profile with URL:",
|
||||
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
|
||||
);
|
||||
const response = await axios.get(
|
||||
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error fetching fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateFitnessProfile: async (
|
||||
userId: string,
|
||||
data: Partial<FitnessProfile>,
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error updating fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createFitnessProfile: async (
|
||||
data: Omit<FitnessProfile, "id">,
|
||||
token: string,
|
||||
): Promise<FitnessProfile> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/api/users/${data.clientId}/fitness-profile`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error creating fitness profile:",
|
||||
error.message,
|
||||
error.response?.data,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "#f5f5f5" },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="sign-in" />
|
||||
<Stack.Screen name="sign-up" />
|
||||
<Stack.Screen name="onboarding" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { fitnessProfileApi } from "@/api/fitnessProfile";
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const { user } = useUser();
|
||||
const { getToken } = useAuth();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [fitnessProfile, setFitnessProfile] = useState({
|
||||
height: "",
|
||||
weight: "",
|
||||
goals: "",
|
||||
fitnessLevel: "beginner",
|
||||
medicalConditions: "",
|
||||
dietaryRestrictions: "",
|
||||
preferredWorkoutTime: "morning",
|
||||
workoutFrequency: "3",
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Validate required fields
|
||||
if (!fitnessProfile.height || !fitnessProfile.weight) {
|
||||
Alert.alert("Error", "Please enter your height and weight");
|
||||
return;
|
||||
}
|
||||
|
||||
const fitnessData = {
|
||||
clientId: user.id,
|
||||
height: parseFloat(fitnessProfile.height),
|
||||
weight: parseFloat(fitnessProfile.weight),
|
||||
goals: fitnessProfile.goals || undefined,
|
||||
fitnessLevel: fitnessProfile.fitnessLevel as
|
||||
| "beginner"
|
||||
| "intermediate"
|
||||
| "advanced",
|
||||
medicalConditions: fitnessProfile.medicalConditions || undefined,
|
||||
dietaryRestrictions: fitnessProfile.dietaryRestrictions || undefined,
|
||||
preferredWorkoutTime: fitnessProfile.preferredWorkoutTime as
|
||||
| "morning"
|
||||
| "afternoon"
|
||||
| "evening",
|
||||
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
|
||||
};
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error("Authentication token not available");
|
||||
}
|
||||
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
||||
router.replace("/(tabs)");
|
||||
} catch (error) {
|
||||
console.error("Error creating fitness profile:", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to create fitness profile. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLevelSelect = (level: string) => {
|
||||
setFitnessProfile({ ...fitnessProfile, fitnessLevel: level });
|
||||
};
|
||||
|
||||
const handleTimeSelect = (time: string) => {
|
||||
setFitnessProfile({ ...fitnessProfile, preferredWorkoutTime: time });
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<Text style={styles.title}>Set Up Your Fitness Profile</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Help us personalize your fitness journey
|
||||
</Text>
|
||||
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.label}>Height (cm)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={fitnessProfile.height}
|
||||
onChangeText={(value) =>
|
||||
setFitnessProfile({ ...fitnessProfile, height: value })
|
||||
}
|
||||
keyboardType="numeric"
|
||||
placeholder="Enter height in cm"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Weight (kg)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={fitnessProfile.weight}
|
||||
onChangeText={(value) =>
|
||||
setFitnessProfile({ ...fitnessProfile, weight: value })
|
||||
}
|
||||
keyboardType="numeric"
|
||||
placeholder="Enter weight in kg"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Fitness Goals</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={fitnessProfile.goals}
|
||||
onChangeText={(value) =>
|
||||
setFitnessProfile({ ...fitnessProfile, goals: value })
|
||||
}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
placeholder="What are your fitness goals?"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Fitness Level</Text>
|
||||
<View style={styles.buttonGroup}>
|
||||
{["beginner", "intermediate", "advanced"].map((level) => (
|
||||
<TouchableOpacity
|
||||
key={level}
|
||||
style={[
|
||||
styles.levelButton,
|
||||
fitnessProfile.fitnessLevel === level && styles.selectedButton,
|
||||
]}
|
||||
onPress={() => handleLevelSelect(level)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.levelButtonText,
|
||||
fitnessProfile.fitnessLevel === level &&
|
||||
styles.selectedButtonText,
|
||||
]}
|
||||
>
|
||||
{level.charAt(0).toUpperCase() + level.slice(1)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.label}>Medical Conditions</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={fitnessProfile.medicalConditions}
|
||||
onChangeText={(value) =>
|
||||
setFitnessProfile({ ...fitnessProfile, medicalConditions: value })
|
||||
}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
placeholder="Any medical conditions we should know about?"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Dietary Restrictions</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={fitnessProfile.dietaryRestrictions}
|
||||
onChangeText={(value) =>
|
||||
setFitnessProfile({
|
||||
...fitnessProfile,
|
||||
dietaryRestrictions: value,
|
||||
})
|
||||
}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
placeholder="Any dietary restrictions?"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Preferred Workout Time</Text>
|
||||
<View style={styles.buttonGroup}>
|
||||
{["morning", "afternoon", "evening"].map((time) => (
|
||||
<TouchableOpacity
|
||||
key={time}
|
||||
style={[
|
||||
styles.timeButton,
|
||||
fitnessProfile.preferredWorkoutTime === time &&
|
||||
styles.selectedButton,
|
||||
]}
|
||||
onPress={() => handleTimeSelect(time)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.timeButtonText,
|
||||
fitnessProfile.preferredWorkoutTime === time &&
|
||||
styles.selectedButtonText,
|
||||
]}
|
||||
>
|
||||
{time.charAt(0).toUpperCase() + time.slice(1)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.label}>Workouts per Week</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={fitnessProfile.workoutFrequency}
|
||||
onChangeText={(value) =>
|
||||
setFitnessProfile({ ...fitnessProfile, workoutFrequency: value })
|
||||
}
|
||||
keyboardType="numeric"
|
||||
placeholder="Number of workouts per week"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.submitButton}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Complete Setup</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginTop: 40,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
textAlign: "center",
|
||||
marginBottom: 32,
|
||||
},
|
||||
form: {
|
||||
padding: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
backgroundColor: "white",
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
buttonGroup: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 16,
|
||||
},
|
||||
levelButton: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f3f4f6",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 4,
|
||||
alignItems: "center",
|
||||
},
|
||||
timeButton: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f3f4f6",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 4,
|
||||
alignItems: "center",
|
||||
},
|
||||
selectedButton: {
|
||||
backgroundColor: "#3b82f6",
|
||||
},
|
||||
levelButtonText: {
|
||||
color: "#374151",
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
timeButtonText: {
|
||||
color: "#374151",
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
selectedButtonText: {
|
||||
color: "white",
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: "#3b82f6",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 24,
|
||||
},
|
||||
submitButtonText: {
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import React from "react";
|
||||
import { useSignIn, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
|
||||
export default function SignInScreen() {
|
||||
const { signIn, setActive, isLoaded } = useSignIn();
|
||||
const { isSignedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [emailAddress, setEmailAddress] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
|
||||
// Redirect if already signed in
|
||||
React.useEffect(() => {
|
||||
if (isSignedIn) {
|
||||
router.replace("/(tabs)");
|
||||
}
|
||||
}, [isSignedIn]);
|
||||
|
||||
const onSignInPress = async () => {
|
||||
if (!isLoaded) return;
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const signInAttempt = await signIn.create({
|
||||
identifier: emailAddress,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInAttempt.status === "complete") {
|
||||
await setActive({ session: signInAttempt.createdSessionId });
|
||||
router.replace("/(tabs)");
|
||||
} else {
|
||||
console.error(
|
||||
"Sign-in incomplete:",
|
||||
JSON.stringify(signInAttempt, null, 2),
|
||||
);
|
||||
setError("Sign-in incomplete. Please try again.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Sign-in error:", JSON.stringify(err, null, 2));
|
||||
|
||||
// Handle specific error codes
|
||||
if (err.errors?.[0]?.code === "session_exists") {
|
||||
// User is already signed in, just redirect
|
||||
router.replace("/(tabs)");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(
|
||||
err.errors?.[0]?.message ||
|
||||
"Failed to sign in. Please check your credentials.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render the form if already signed in
|
||||
if (isSignedIn) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text style={styles.loadingText}>Redirecting...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Sign in to continue to FitAI</Text>
|
||||
|
||||
{error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
value={emailAddress}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
onChangeText={setEmailAddress}
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
placeholder="Enter your password"
|
||||
placeholderTextColor="#999"
|
||||
secureTextEntry={true}
|
||||
onChangeText={setPassword}
|
||||
autoComplete="password"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={onSignInPress}
|
||||
disabled={loading || !emailAddress || !password}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Don't have an account? </Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(auth)/sign-up")}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.linkText}>Sign Up</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
centerContent: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
marginBottom: 32,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: "#fee",
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#f44",
|
||||
},
|
||||
errorText: {
|
||||
color: "#c00",
|
||||
fontSize: 14,
|
||||
},
|
||||
form: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#333",
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: "#fff",
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#2563eb",
|
||||
paddingVertical: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: "#93c5fd",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: "#2563eb",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import React from "react";
|
||||
import { useSignUp, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
|
||||
export default function SignUpScreen() {
|
||||
const { signUp, setActive, isLoaded } = useSignUp();
|
||||
const { isSignedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [emailAddress, setEmailAddress] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [firstName, setFirstName] = React.useState("");
|
||||
const [lastName, setLastName] = React.useState("");
|
||||
const [pendingVerification, setPendingVerification] = React.useState(false);
|
||||
const [code, setCode] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
|
||||
// Redirect if already signed in
|
||||
React.useEffect(() => {
|
||||
if (isSignedIn) {
|
||||
router.replace("/(tabs)");
|
||||
}
|
||||
}, [isSignedIn]);
|
||||
|
||||
const onSignUpPress = async () => {
|
||||
if (!isLoaded) return;
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await signUp.create({
|
||||
emailAddress,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
// Send email verification code
|
||||
await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
|
||||
|
||||
setPendingVerification(true);
|
||||
} catch (err: any) {
|
||||
console.error("Sign-up error:", JSON.stringify(err, null, 2));
|
||||
setError(
|
||||
err.errors?.[0]?.message || "Failed to sign up. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onVerifyPress = async () => {
|
||||
if (!isLoaded) return;
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const completeSignUp = await signUp.attemptEmailAddressVerification({
|
||||
code,
|
||||
});
|
||||
|
||||
if (completeSignUp.status === "complete") {
|
||||
await setActive({ session: completeSignUp.createdSessionId });
|
||||
router.replace("/(tabs)");
|
||||
} else {
|
||||
console.error(
|
||||
"Verification incomplete:",
|
||||
JSON.stringify(completeSignUp, null, 2),
|
||||
);
|
||||
setError("Verification incomplete. Please try again.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Verification error:", JSON.stringify(err, null, 2));
|
||||
|
||||
// Handle specific error codes
|
||||
if (err.errors?.[0]?.code === "session_exists") {
|
||||
// User is already signed in, just redirect
|
||||
router.replace("/(tabs)");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.errors?.[0]?.message || "Invalid verification code.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render the form if already signed in
|
||||
if (isSignedIn) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text style={styles.loadingText}>Redirecting...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingVerification) {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Verify Email</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Enter the verification code sent to {emailAddress}
|
||||
</Text>
|
||||
|
||||
{error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Verification Code</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={code}
|
||||
placeholder="Enter verification code"
|
||||
placeholderTextColor="#999"
|
||||
onChangeText={setCode}
|
||||
keyboardType="number-pad"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={onVerifyPress}
|
||||
disabled={loading || !code}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Verify Email</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Sign up to get started with FitAI</Text>
|
||||
|
||||
{error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>First Name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={firstName}
|
||||
placeholder="Enter your first name"
|
||||
placeholderTextColor="#999"
|
||||
onChangeText={setFirstName}
|
||||
autoComplete="given-name"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Last Name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={lastName}
|
||||
placeholder="Enter your last name"
|
||||
placeholderTextColor="#999"
|
||||
onChangeText={setLastName}
|
||||
autoComplete="family-name"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
value={emailAddress}
|
||||
placeholder="Enter your email"
|
||||
placeholderTextColor="#999"
|
||||
onChangeText={setEmailAddress}
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
editable={!loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
placeholder="Create a password"
|
||||
placeholderTextColor="#999"
|
||||
secureTextEntry={true}
|
||||
onChangeText={setPassword}
|
||||
autoComplete="password-new"
|
||||
editable={!loading}
|
||||
/>
|
||||
<Text style={styles.hint}>Must be at least 8 characters</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={onSignUpPress}
|
||||
disabled={
|
||||
loading || !emailAddress || !password || !firstName || !lastName
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign Up</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Already have an account? </Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(auth)/sign-in")}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.linkText}>Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
centerContent: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
marginBottom: 32,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: "#fee",
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#f44",
|
||||
},
|
||||
errorText: {
|
||||
color: "#c00",
|
||||
fontSize: 14,
|
||||
},
|
||||
form: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#333",
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: "#fff",
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
marginTop: 4,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#2563eb",
|
||||
paddingVertical: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: "#93c5fd",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: "#2563eb",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -1,36 +1,72 @@
|
||||
import { Tabs } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Tabs, useRouter, useSegments } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { isSignedIn, isLoaded } = useAuth();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return;
|
||||
|
||||
const inAuthGroup = segments[0] === "(auth)";
|
||||
|
||||
if (!isSignedIn && !inAuthGroup) {
|
||||
// Redirect to sign-in if not authenticated
|
||||
router.replace("/(auth)/sign-in");
|
||||
}
|
||||
}, [isSignedIn, isLoaded, segments]);
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: "#2563eb",
|
||||
tabBarInactiveTintColor: "#6b7280",
|
||||
headerShown: true,
|
||||
headerStyle: {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
headerTitleStyle: {
|
||||
fontWeight: "600",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
title: "Home",
|
||||
headerTitle: "FitAI",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="home" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="attendance"
|
||||
options={{
|
||||
title: 'Attendance',
|
||||
title: "Attendance",
|
||||
headerTitle: "Attendance",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="calendar" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: "Profile",
|
||||
headerTitle: "My Profile",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,293 @@
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useRequireAuth } from '@/hooks/useRequireAuth'
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, ScrollView } from "react-native";
|
||||
import { useUser } from "@clerk/clerk-expo";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user } = useRequireAuth()
|
||||
const { user, isLoaded } = useUser();
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
if (!isLoaded || !user) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const firstName = user.firstName || "User";
|
||||
const greeting = getGreeting();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Welcome back!</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
<Text style={styles.email}>{user.email}</Text>
|
||||
</View>
|
||||
)
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{/* Welcome Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.greeting}>{greeting}!</Text>
|
||||
<Text style={styles.name}>{firstName}</Text>
|
||||
</View>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Ionicons name="calendar-outline" size={32} color="#2563eb" />
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>This Month</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<Ionicons name="flame-outline" size={32} color="#ef4444" />
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>Day Streak</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<Ionicons name="trophy-outline" size={32} color="#f59e0b" />
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>Total Visits</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
|
||||
<View style={styles.actionButton}>
|
||||
<View style={styles.actionIcon}>
|
||||
<Ionicons name="log-in-outline" size={24} color="#2563eb" />
|
||||
</View>
|
||||
<View style={styles.actionContent}>
|
||||
<Text style={styles.actionTitle}>Check In</Text>
|
||||
<Text style={styles.actionSubtitle}>
|
||||
Start your workout session
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButton}>
|
||||
<View style={styles.actionIcon}>
|
||||
<Ionicons name="calendar-outline" size={24} color="#10b981" />
|
||||
</View>
|
||||
<View style={styles.actionContent}>
|
||||
<Text style={styles.actionTitle}>View Schedule</Text>
|
||||
<Text style={styles.actionSubtitle}>
|
||||
Check your upcoming classes
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButton}>
|
||||
<View style={styles.actionIcon}>
|
||||
<Ionicons name="card-outline" size={24} color="#8b5cf6" />
|
||||
</View>
|
||||
<View style={styles.actionContent}>
|
||||
<Text style={styles.actionTitle}>Payments</Text>
|
||||
<Text style={styles.actionSubtitle}>View payment history</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Membership Info */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Membership</Text>
|
||||
<View style={styles.membershipCard}>
|
||||
<View style={styles.membershipHeader}>
|
||||
<Text style={styles.membershipType}>Basic Plan</Text>
|
||||
<View style={styles.statusBadge}>
|
||||
<Text style={styles.statusText}>Active</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.membershipEmail}>
|
||||
{user.primaryEmailAddress?.emailAddress}
|
||||
</Text>
|
||||
<Text style={styles.membershipDate}>
|
||||
Member since {new Date(user.createdAt!).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Recent Activity</Text>
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="barbell-outline" size={48} color="#d1d5db" />
|
||||
<Text style={styles.emptyStateText}>No recent activity</Text>
|
||||
<Text style={styles.emptyStateSubtext}>
|
||||
Check in to start tracking your workouts
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 18) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
title: {
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 16,
|
||||
color: "#6b7280",
|
||||
marginBottom: 4,
|
||||
},
|
||||
name: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: "bold",
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 24,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: "center",
|
||||
marginHorizontal: 4,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#1a1a1a",
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 8,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
actionIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: "#f0f9ff",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
actionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
actionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 2,
|
||||
},
|
||||
actionSubtitle: {
|
||||
fontSize: 14,
|
||||
color: "#6b7280",
|
||||
},
|
||||
membershipCard: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
membershipHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
membershipType: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
statusBadge: {
|
||||
backgroundColor: "#dcfce7",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
color: "#16a34a",
|
||||
},
|
||||
membershipEmail: {
|
||||
fontSize: 14,
|
||||
color: "#6b7280",
|
||||
marginBottom: 4,
|
||||
color: '#333',
|
||||
},
|
||||
email: {
|
||||
membershipDate: {
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
},
|
||||
emptyState: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 32,
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
fontWeight: "600",
|
||||
color: "#6b7280",
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
})
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
color: "#9ca3af",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
@@ -1,116 +1,307 @@
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useRouter } from 'expo-router'
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const { user } = useUser();
|
||||
const { signOut } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
Alert.alert(
|
||||
'Logout',
|
||||
'Are you sure you want to logout?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Logout',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await logout()
|
||||
router.replace('/login')
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to logout')
|
||||
}
|
||||
},
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await signOut();
|
||||
router.replace("/(auth)/sign-in");
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to sign out");
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.profileCard}>
|
||||
<Text style={styles.title}>Profile</Text>
|
||||
<Text style={styles.name}>
|
||||
{user?.firstName} {user?.lastName}
|
||||
</Text>
|
||||
<Text style={styles.email}>{user?.email}</Text>
|
||||
{user?.phone && <Text style={styles.phone}>{user.phone}</Text>}
|
||||
|
||||
<View style={styles.roleBadge}>
|
||||
<Text style={styles.roleText}>
|
||||
{user?.role.charAt(0).toUpperCase() + user?.role.slice(1)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{/* Profile Header */}
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.avatarContainer}>
|
||||
{user.imageUrl ? (
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>
|
||||
{user.firstName?.charAt(0)}
|
||||
{user.lastName?.charAt(0)}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.avatar}>
|
||||
<Ionicons name="person" size={40} color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Text style={styles.logoutText}>Logout</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
<Text style={styles.name}>
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
<Text style={styles.email}>
|
||||
{user.primaryEmailAddress?.emailAddress}
|
||||
</Text>
|
||||
|
||||
{user.primaryPhoneNumber && (
|
||||
<Text style={styles.phone}>
|
||||
{user.primaryPhoneNumber.phoneNumber}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Account Information */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account Information</Text>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabel}>
|
||||
<Ionicons name="mail-outline" size={20} color="#666" />
|
||||
<Text style={styles.infoLabelText}>Email</Text>
|
||||
</View>
|
||||
<Text style={styles.infoValue}>
|
||||
{user.primaryEmailAddress?.emailAddress}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{user.primaryPhoneNumber && (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabel}>
|
||||
<Ionicons name="call-outline" size={20} color="#666" />
|
||||
<Text style={styles.infoLabelText}>Phone</Text>
|
||||
</View>
|
||||
<Text style={styles.infoValue}>
|
||||
{user.primaryPhoneNumber.phoneNumber}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabel}>
|
||||
<Ionicons name="calendar-outline" size={20} color="#666" />
|
||||
<Text style={styles.infoLabelText}>Member Since</Text>
|
||||
</View>
|
||||
<Text style={styles.infoValue}>
|
||||
{new Date(user.createdAt!).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabel}>
|
||||
<Ionicons
|
||||
name="shield-checkmark-outline"
|
||||
size={20}
|
||||
color="#666"
|
||||
/>
|
||||
<Text style={styles.infoLabelText}>Email Verified</Text>
|
||||
</View>
|
||||
<Text style={styles.infoValue}>
|
||||
{user.primaryEmailAddress?.verification?.status === "verified"
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="person-outline" size={24} color="#2563eb" />
|
||||
<Text style={styles.actionButtonText}>Edit Profile</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#999" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="notifications-outline" size={24} color="#2563eb" />
|
||||
<Text style={styles.actionButtonText}>Notifications</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#999" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="card-outline" size={24} color="#2563eb" />
|
||||
<Text style={styles.actionButtonText}>Payment History</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#999" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="settings-outline" size={24} color="#2563eb" />
|
||||
<Text style={styles.actionButtonText}>Settings</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#999" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Sign Out Button */}
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Ionicons name="log-out-outline" size={20} color="#fff" />
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* App Version */}
|
||||
<Text style={styles.version}>Version 1.0.0</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
profileCard: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
backgroundColor: "white",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
avatarContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: "#2563eb",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
},
|
||||
name: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 4,
|
||||
},
|
||||
email: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
color: "#666",
|
||||
marginBottom: 4,
|
||||
},
|
||||
phone: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
fontSize: 14,
|
||||
color: "#999",
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 16,
|
||||
},
|
||||
roleBadge: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
},
|
||||
roleText: {
|
||||
color: 'white',
|
||||
infoLabel: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
infoLabelText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: "#666",
|
||||
fontWeight: "500",
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
color: "#1a1a1a",
|
||||
fontWeight: "500",
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
},
|
||||
actionButtonText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: "#1a1a1a",
|
||||
marginLeft: 12,
|
||||
fontWeight: "500",
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: '#ef4444',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: 24,
|
||||
backgroundColor: "#ef4444",
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#ef4444",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
logoutText: {
|
||||
color: 'white',
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontWeight: "600",
|
||||
},
|
||||
})
|
||||
version: {
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
marginTop: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
});
|
||||
@@ -1,15 +1,52 @@
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
import { Stack } from 'expo-router'
|
||||
import { View, Text } from 'react-native'
|
||||
import { ClerkProvider, ClerkLoaded } from "@clerk/clerk-expo";
|
||||
import { Stack } from "expo-router";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
// Token cache for Clerk
|
||||
const tokenCache = {
|
||||
async getToken(key: string) {
|
||||
try {
|
||||
return SecureStore.getItemAsync(key);
|
||||
} catch (err) {
|
||||
console.error("Error getting token:", err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async saveToken(key: string, value: string) {
|
||||
try {
|
||||
return SecureStore.setItemAsync(key, value);
|
||||
} catch (err) {
|
||||
console.error("Error saving token:", err);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||
|
||||
if (!publishableKey) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Missing Clerk Publishable Key</Text>
|
||||
<Text
|
||||
style={{ marginTop: 8, textAlign: "center", paddingHorizontal: 20 }}
|
||||
>
|
||||
Please add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="register" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
||||
<ClerkLoaded>
|
||||
<Stack>
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="welcome" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</ClerkLoaded>
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import axios from 'axios'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formData.email || !formData.password) {
|
||||
Alert.alert('Error', 'Please fill in all fields')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.LOGIN}`, formData)
|
||||
|
||||
if (response.data.user) {
|
||||
await SecureStore.setItemAsync('user', JSON.stringify(response.data.user))
|
||||
|
||||
// Check if user has completed fitness profile
|
||||
try {
|
||||
const profileResponse = await axios.get(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}?userId=${response.data.user.id}`
|
||||
)
|
||||
|
||||
if (profileResponse.data.profile) {
|
||||
// User has profile, go to main app
|
||||
Alert.alert('Success', 'Login successful!', [
|
||||
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
|
||||
])
|
||||
} else {
|
||||
// New user, go to welcome page
|
||||
Alert.alert('Welcome!', 'Let\'s set up your fitness profile', [
|
||||
{ text: 'OK', onPress: () => router.replace('/welcome') }
|
||||
])
|
||||
}
|
||||
} catch (profileError) {
|
||||
// Profile doesn't exist or server error, treat as new user
|
||||
console.log('Profile check failed:', profileError)
|
||||
Alert.alert('Welcome!', 'Let\'s set up your fitness profile', [
|
||||
{ text: 'OK', onPress: () => router.replace('/welcome') }
|
||||
])
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.response?.data?.error || 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Login to your FitAI account</Text>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={formData.email}
|
||||
onChangeText={(text) => setFormData({ ...formData, email: text })}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChangeText={(text) => setFormData({ ...formData, password: text })}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.linkButton}
|
||||
onPress={() => router.push('/register')}
|
||||
>
|
||||
<Text style={styles.linkText}>
|
||||
Don't have an account? Register
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginBottom: 32,
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: 'white',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#9ca3af',
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
linkButton: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
color: '#3b82f6',
|
||||
fontSize: 14,
|
||||
},
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import axios from 'axios'
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
|
||||
Alert.alert('Error', 'Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.REGISTER}`, formData)
|
||||
|
||||
if (response.status === 201) {
|
||||
Alert.alert('Success', 'Registration successful! Please login.', [
|
||||
{ text: 'OK', onPress: () => router.push('/login') }
|
||||
])
|
||||
}
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.response?.data?.error || 'Registration failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Join FitAI today</Text>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="First Name"
|
||||
value={formData.firstName}
|
||||
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Last Name"
|
||||
value={formData.lastName}
|
||||
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={formData.email}
|
||||
onChangeText={(text) => setFormData({ ...formData, email: text })}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Phone (optional)"
|
||||
value={formData.phone}
|
||||
onChangeText={(text) => setFormData({ ...formData, phone: text })}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChangeText={(text) => setFormData({ ...formData, password: text })}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Creating Account...' : 'Create Account'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.linkButton}
|
||||
onPress={() => router.push('/login')}
|
||||
>
|
||||
<Text style={styles.linkText}>
|
||||
Already have an account? Login
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginBottom: 32,
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: 'white',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#9ca3af',
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
linkButton: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
color: '#3b82f6',
|
||||
fontSize: 14,
|
||||
},
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
login: (user: User) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const userData = await SecureStore.getItemAsync('user')
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (userData: User) => {
|
||||
try {
|
||||
await SecureStore.setItemAsync('user', JSON.stringify(userData))
|
||||
setUser(userData)
|
||||
} catch (error) {
|
||||
console.error('Failed to save user:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await SecureStore.deleteItemAsync('user')
|
||||
setUser(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to logout:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useAuth, useUser } from "@clerk/clerk-expo";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useRequireAuth() {
|
||||
const { user, isLoading } = useAuth()
|
||||
const router = useRouter()
|
||||
const { isLoaded, isSignedIn } = useAuth();
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.replace('/login')
|
||||
}
|
||||
}, [user, isLoading, router])
|
||||
if (!isLoaded) return;
|
||||
|
||||
return { user, isLoading }
|
||||
}
|
||||
const inAuthGroup = segments[0] === "(auth)";
|
||||
|
||||
if (!isSignedIn && !inAuthGroup) {
|
||||
// Redirect to sign-in if not authenticated
|
||||
router.replace("/(auth)/sign-in");
|
||||
}
|
||||
}, [isSignedIn, isLoaded, segments]);
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoading: !isLoaded,
|
||||
isSignedIn,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user