basic clerk auth

need polishing
This commit is contained in:
echo 2025-11-09 19:46:30 +01:00
parent ca790a7b97
commit 73907568ef
20 changed files with 2334 additions and 1123 deletions

View File

@ -43,3 +43,5 @@ android/app/build/generated/
# Bundle artifact # Bundle artifact
*.jsbundle *.jsbundle
# clerk configuration (can include secrets)
/.clerk/

Binary file not shown.

12
apps/admin/middleware.ts Normal file
View File

@ -0,0 +1,12 @@
import { clerkMiddleware } from '@clerk/nextjs/server'
export default clerkMiddleware()
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)(.*)',
],
}

View File

@ -8,6 +8,7 @@
"name": "@fitai/admin", "name": "@fitai/admin",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@clerk/nextjs": "^6.34.5",
"@fitai/shared": "file:../../packages/shared", "@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
@ -600,6 +601,103 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@emnapi/core": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz",
@ -2203,6 +2301,12 @@
"@sinonjs/commons": "^3.0.1" "@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": { "node_modules/@standard-schema/spec": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@ -4607,6 +4711,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4633,7 +4746,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": { "node_modules/d3-array": {
@ -4954,9 +5066,7 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -5933,6 +6043,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fastq": {
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -6414,6 +6530,12 @@
"node": ">=10.13.0" "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": { "node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@ -8333,6 +8455,15 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -10652,6 +10783,12 @@
"node": ">=10" "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": { "node_modules/set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -11091,6 +11228,22 @@
"node": ">=8" "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": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@ -11433,6 +11586,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"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": { "node_modules/synckit": {
"version": "0.11.11", "version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",

View File

@ -11,6 +11,7 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^6.34.5",
"@fitai/shared": "file:../../packages/shared", "@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",

View File

@ -1,22 +1,27 @@
import type { Metadata } from 'next' import type { Metadata } from "next";
import { Inter } from 'next/font/google' import { Inter } from "next/font/google";
import './globals.css' import "./globals.css";
import { ClerkProvider } from "@clerk/nextjs";
const inter = Inter({ subsets: ['latin'] }) const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'FitAI Admin', title: "FitAI Admin",
description: 'Fitness management admin dashboard', description: "Fitness management admin dashboard",
} };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) { }) {
return ( return (
<ClerkProvider
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
>
<html lang="en"> <html lang="en">
<body className={inter.className}>{children}</body> <body className={inter.className}>{children}</body>
</html> </html>
) </ClerkProvider>
);
} }

View File

@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { UserManagement } from "@/components/users/UserManagement"; import { UserManagement } from "@/components/users/UserManagement";
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard"; import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";
export default function Home() { export default function Home() {
return ( return (
@ -12,22 +13,17 @@ export default function Home() {
<h1 className="text-4xl font-bold text-gray-900"> <h1 className="text-4xl font-bold text-gray-900">
FitAI Admin Dashboard FitAI Admin Dashboard
</h1> </h1>
<nav className="flex gap-4"> <div className="flex items-center gap-4">
<Link <SignedOut>
href="/users" <SignInButton />
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors" </SignedOut>
> <SignedIn>
User Management <UserButton />
</Link> </SignedIn>
<Link </div>
href="/analytics"
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
Analytics
</Link>
</nav>
</div> </div>
<SignedIn>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Client Management</h2> <h2 className="text-xl font-semibold mb-4">Client Management</h2>
@ -37,11 +33,15 @@ export default function Home() {
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Payment Tracking</h2> <h2 className="text-xl font-semibold mb-4">Payment Tracking</h2>
<p className="text-gray-600">Monitor payments and subscriptions</p> <p className="text-gray-600">
Monitor payments and subscriptions
</p>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Attendance</h2> <h2 className="text-xl font-semibold mb-4">Attendance</h2>
<p className="text-gray-600">Track client attendance and habits</p> <p className="text-gray-600">
Track client attendance and habits
</p>
</div> </div>
</div> </div>
@ -62,6 +62,16 @@ export default function Home() {
</div> </div>
</div> </div>
</div> </div>
</SignedIn>
<SignedOut>
<div className="text-center">
<p className="text-gray-600 mb-4">
Please sign in to access the admin dashboard.
</p>
<SignInButton />
</div>
</SignedOut>
</div> </div>
</main> </main>
); );

View File

@ -5,7 +5,7 @@ import { AgCharts } from "ag-charts-react";
import { AgChartOptions } from "ag-charts-community"; import { AgChartOptions } from "ag-charts-community";
interface BarData { interface BarData {
category: string; label: string;
value: number; value: number;
color?: string; color?: string;
} }
@ -30,7 +30,7 @@ export function RevenueChart({
series: [ series: [
{ {
type: "bar", type: "bar",
xKey: "category", xKey: "label",
yKey: "value", yKey: "value",
fills: data.map((item) => item.color || "#10b981"), fills: data.map((item) => item.color || "#10b981"),
strokes: ["#ffffff"], strokes: ["#ffffff"],
@ -55,7 +55,7 @@ export function RevenueChart({
enabled: true, enabled: true,
renderer: (params: any) => { renderer: (params: any) => {
return `<div class="bg-white p-2 rounded shadow-lg border"> return `<div class="bg-white p-2 rounded shadow-lg border">
<div class="font-bold">${params.datum.category}</div> <div class="font-bold">${params.datum.label}</div>
<div class="text-sm">Revenue: $${params.datum.value.toLocaleString()}</div> <div class="text-sm">Revenue: $${params.datum.value.toLocaleString()}</div>
</div>`; </div>`;
}, },

View File

@ -163,14 +163,17 @@ export function UserGrid({
const gridRef = React.useRef<AgGridReact<User>>(null); const gridRef = React.useRef<AgGridReact<User>>(null);
const gridOptions = { const gridOptions = {
theme: "legacy", theme: "legacy" as const,
columnDefs, columnDefs,
defaultColDef, defaultColDef,
rowData: users, rowData: users,
rowSelection: "multiple", rowSelection: { mode: "multiRow" as const },
onSelectionChanged: () => { onSelectionChanged: () => {
const selectedNodes = gridRef.current?.api.getSelectedNodes(); const selectedNodes = gridRef.current?.api.getSelectedNodes();
const selectedData = selectedNodes?.map((node) => node.data) || []; const selectedData =
selectedNodes
?.map((node) => node.data)
.filter((data): data is User => data !== undefined) || [];
setSelectedUsers(selectedData); setSelectedUsers(selectedData);
if (selectedData.length === 1 && onUserSelect) { if (selectedData.length === 1 && onUserSelect) {
onUserSelect(selectedData[0]); onUserSelect(selectedData[0]);

View File

@ -191,14 +191,14 @@ export function UserManagement() {
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleEditUser} onClick={() => handleEditUser(selectedUser!)}
disabled={!selectedUser} disabled={!selectedUser}
> >
Edit User Edit User
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleDeleteUser} onClick={() => handleDeleteUser(selectedUser!)}
disabled={!selectedUser} disabled={!selectedUser}
> >
Delete User Delete User

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0", "@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0", "@hookform/resolvers": "^3.3.0",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
@ -21,12 +22,15 @@
"ajv-keywords": "^5.1.0", "ajv-keywords": "^5.1.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "~54.0.23", "expo": "~54.0.23",
"expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0", "expo-camera": "~17.0.0",
"expo-linking": "~8.0.0", "expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0", "expo-notifications": "~0.32.0",
"expo-router": "~6.0.14", "expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7", "expo-secure-store": "~15.0.7",
"expo-web-browser": "^15.0.9",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
@ -35,15 +39,15 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@testing-library/react-native": "^12.4.0",
"@types/react": "~19.1.10", "@types/react": "~19.1.10",
"@types/react-native": "^0.73.0", "@types/react-native": "^0.73.0",
"typescript": "^5.1.3",
"eslint": "^8.45.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
"babel-preset-expo": "~54.0.7",
"eslint": "^8.45.0",
"jest": "^29.2.1", "jest": "^29.2.1",
"@testing-library/react-native": "^12.4.0",
"react-test-renderer": "19.1.0", "react-test-renderer": "19.1.0",
"babel-preset-expo": "~54.0.7" "typescript": "^5.1.3"
} }
} }

View File

@ -1,5 +1,5 @@
import { Tabs } from 'expo-router' import { Tabs } from "expo-router";
import { Ionicons } from '@expo/vector-icons' import { Ionicons } from "@expo/vector-icons";
export default function TabLayout() { export default function TabLayout() {
return ( return (
@ -7,7 +7,7 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Home', title: "Home",
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} /> <Ionicons name="home" size={size} color={color} />
), ),
@ -16,7 +16,7 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="profile" name="profile"
options={{ options={{
title: 'Profile', title: "Profile",
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} /> <Ionicons name="person" size={size} color={color} />
), ),
@ -25,12 +25,12 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="attendance" name="attendance"
options={{ options={{
title: 'Attendance', title: "Attendance",
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="calendar" size={size} color={color} /> <Ionicons name="calendar" size={size} color={color} />
), ),
}} }}
/> />
</Tabs> </Tabs>
) );
} }

View File

@ -1,33 +1,32 @@
import React from 'react' import React, { useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native' import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { useAuth } from '@/contexts/AuthContext' import { useUser, useAuth } from "@clerk/clerk-expo";
import { useRouter } from 'expo-router' import { useRouter } from "expo-router";
import axios from "axios";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, logout } = useAuth() const { user } = useUser();
const router = useRouter() const { signOut } = useAuth();
const router = useRouter();
const handleLogout = async () => { useEffect(() => {
Alert.alert( const checkProfile = async () => {
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
try { try {
await logout() const response = await axios.get(
router.replace('/login') `${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}`,
);
if (response.status === 200 && response.data) {
router.replace("/activities");
}
} catch (error) { } catch (error) {
Alert.alert('Error', 'Failed to logout') // Profile not found, stay on profile
} }
}, };
}, if (user) {
] checkProfile();
)
} }
}, [user, router]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@ -36,35 +35,39 @@ export default function ProfileScreen() {
<Text style={styles.name}> <Text style={styles.name}>
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</Text> </Text>
<Text style={styles.email}>{user?.email}</Text> <Text style={styles.email}>
{user?.phone && <Text style={styles.phone}>{user.phone}</Text>} {user?.primaryEmailAddress?.emailAddress}
</Text>
{user?.phoneNumbers?.[0] && (
<Text style={styles.phone}>{user.phoneNumbers[0].phoneNumber}</Text>
)}
<View style={styles.roleBadge}> <View style={styles.roleBadge}>
<Text style={styles.roleText}> <Text style={styles.roleText}>User</Text>
{user?.role.charAt(0).toUpperCase() + user?.role.slice(1)}
</Text>
</View> </View>
</View> </View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}> <View style={styles.logoutButton}>
<TouchableOpacity onPress={() => signOut()}>
<Text style={styles.logoutText}>Logout</Text> <Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) </View>
);
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
padding: 20, padding: 20,
backgroundColor: '#f5f5f5', backgroundColor: "#f5f5f5",
}, },
profileCard: { profileCard: {
backgroundColor: 'white', backgroundColor: "white",
borderRadius: 12, borderRadius: 12,
padding: 24, padding: 24,
alignItems: 'center', alignItems: "center",
shadowColor: '#000', shadowColor: "#000",
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 4, shadowRadius: 4,
@ -72,45 +75,45 @@ const styles = StyleSheet.create({
}, },
title: { title: {
fontSize: 24, fontSize: 24,
fontWeight: 'bold', fontWeight: "bold",
marginBottom: 16, marginBottom: 16,
}, },
name: { name: {
fontSize: 20, fontSize: 20,
fontWeight: '600', fontWeight: "600",
marginBottom: 4, marginBottom: 4,
}, },
email: { email: {
fontSize: 16, fontSize: 16,
color: '#666', color: "#666",
marginBottom: 4, marginBottom: 4,
}, },
phone: { phone: {
fontSize: 16, fontSize: 16,
color: '#666', color: "#666",
marginBottom: 16, marginBottom: 16,
}, },
roleBadge: { roleBadge: {
backgroundColor: '#3b82f6', backgroundColor: "#3b82f6",
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 6, paddingVertical: 6,
borderRadius: 20, borderRadius: 20,
}, },
roleText: { roleText: {
color: 'white', color: "white",
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: "600",
}, },
logoutButton: { logoutButton: {
backgroundColor: '#ef4444', backgroundColor: "#ef4444",
paddingVertical: 14, paddingVertical: 14,
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: "center",
marginTop: 24, marginTop: 24,
}, },
logoutText: { logoutText: {
color: 'white', color: "white",
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: "600",
}, },
}) });

View File

@ -1,15 +1,12 @@
import { AuthProvider } from '@/contexts/AuthContext' import { ClerkProvider } from "@clerk/clerk-expo";
import { Stack } from 'expo-router' import { Slot } from "expo-router";
import { View, Text } from 'react-native'
export default function RootLayout() { export default function RootLayout() {
return ( return (
<AuthProvider> <ClerkProvider
<Stack> publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY}
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> >
<Stack.Screen name="login" options={{ headerShown: false }} /> <Slot />
<Stack.Screen name="register" options={{ headerShown: false }} /> </ClerkProvider>
</Stack> );
</AuthProvider>
)
} }

View File

@ -1,161 +1,139 @@
import React, { useState } from 'react' import { useSignIn, useOAuth, useUser } from "@clerk/clerk-expo";
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native' import { useState } from "react";
import { useRouter } from 'expo-router' import {
import axios from 'axios' View,
import * as SecureStore from 'expo-secure-store' Text,
import { API_BASE_URL, API_ENDPOINTS } from '../config/api' TextInput,
TouchableOpacity,
Alert,
StyleSheet,
} from "react-native";
import { useRouter } from "expo-router";
import axios from "axios";
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
export default function LoginScreen() { export default function LoginScreen() {
const [formData, setFormData] = useState({ const { signIn, setActive } = useSignIn();
email: '', const { startOAuthFlow } = useOAuth({ strategy: "oauth_google" });
password: '', const { user } = useUser();
}) const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false) const [password, setPassword] = useState("");
const router = useRouter() const router = useRouter();
const handleLogin = async () => { const handleSignIn = async () => {
if (!formData.email || !formData.password) { if (!email || !password) {
Alert.alert('Error', 'Please fill in all fields') Alert.alert("Error", "Please fill in all fields");
return return;
} }
setLoading(true)
try { try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.LOGIN}`, formData) const result = await signIn.create({
identifier: email,
password,
});
if (response.data.user) { if (result.status === "complete") {
await SecureStore.setItemAsync('user', JSON.stringify(response.data.user)) await setActive({ session: result.createdSessionId });
router.replace("/(tabs)/profile");
// 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 { } else {
// New user, go to welcome page Alert.alert("Error", "Sign in failed");
Alert.alert('Welcome!', 'Let\'s set up your fitness profile', [
{ text: 'OK', onPress: () => router.replace('/welcome') }
])
} }
} catch (profileError) { } catch (err: any) {
// Profile doesn't exist or server error, treat as new user Alert.alert("Error", err.message || "Sign in failed");
console.log('Profile check failed:', profileError)
Alert.alert('Welcome!', 'Let\'s set up your fitness profile', [
{ text: 'OK', onPress: () => router.replace('/welcome') }
])
} }
};
const handleGoogleSignIn = async () => {
try {
const result = await startOAuthFlow();
if (result.createdSessionId) {
router.replace("/(tabs)/profile");
} }
} catch (error: any) { } catch (err: any) {
Alert.alert('Error', error.response?.data?.error || 'Login failed') Alert.alert("Error", err.message || "Google sign in failed");
} finally {
setLoading(false)
}
} }
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.title}>Welcome Back</Text> <Text style={styles.title}>Sign In</Text>
<Text style={styles.subtitle}>Login to your FitAI account</Text>
<View style={styles.form}>
<TextInput <TextInput
style={styles.input}
placeholder="Email" placeholder="Email"
value={formData.email} value={email}
onChangeText={(text) => setFormData({ ...formData, email: text })} onChangeText={setEmail}
style={styles.input}
keyboardType="email-address" keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
/> />
<TextInput <TextInput
style={styles.input}
placeholder="Password" placeholder="Password"
value={formData.password} value={password}
onChangeText={(text) => setFormData({ ...formData, password: text })} onChangeText={setPassword}
style={styles.input}
secureTextEntry secureTextEntry
/> />
<TouchableOpacity onPress={handleSignIn} style={styles.button}>
<TouchableOpacity <Text style={styles.buttonText}>Sign In</Text>
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Logging in...' : 'Login'}
</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.orText}>Or</Text>
<TouchableOpacity <TouchableOpacity
style={styles.linkButton} onPress={handleGoogleSignIn}
onPress={() => router.push('/register')} style={styles.googleButton}
> >
<Text style={styles.linkText}> <Text style={styles.googleButtonText}>Sign In with Google</Text>
Don't have an account? Register
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> );
)
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: "center",
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20, padding: 20,
backgroundColor: "#f5f5f5",
}, },
title: { title: {
fontSize: 32, fontSize: 24,
fontWeight: 'bold', fontWeight: "bold",
marginBottom: 8, textAlign: "center",
color: '#333', marginBottom: 20,
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
}, },
input: { input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: "#ddd",
padding: 15,
marginBottom: 15,
borderRadius: 8,
backgroundColor: "white",
}, },
button: { button: {
backgroundColor: '#3b82f6', backgroundColor: "#3b82f6",
paddingVertical: 14, padding: 15,
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: "center",
marginBottom: 16, marginBottom: 10,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
}, },
buttonText: { buttonText: {
color: 'white', color: "white",
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: "600",
}, },
linkButton: { orText: {
alignItems: 'center', textAlign: "center",
marginVertical: 10,
fontSize: 16,
color: "#666",
}, },
linkText: { googleButton: {
color: '#3b82f6', backgroundColor: "#db4437",
fontSize: 14, padding: 15,
borderRadius: 8,
alignItems: "center",
}, },
}) googleButtonText: {
color: "white",
fontSize: 16,
fontWeight: "600",
},
});

View File

@ -1,164 +1,117 @@
import React, { useState } from 'react' import { useSignUp } from "@clerk/clerk-expo";
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native' import { useState } from "react";
import { useRouter } from 'expo-router' import {
import axios from 'axios' View,
import { API_BASE_URL, API_ENDPOINTS } from '../config/api' Text,
TextInput,
TouchableOpacity,
Alert,
StyleSheet,
} from "react-native";
import { useRouter } from "expo-router";
export default function RegisterScreen() { export default function RegisterScreen() {
const [formData, setFormData] = useState({ const { signUp, setActive } = useSignUp();
email: '', const [email, setEmail] = useState("");
password: '', const [password, setPassword] = useState("");
firstName: '', const [firstName, setFirstName] = useState("");
lastName: '', const [lastName, setLastName] = useState("");
phone: '', const router = useRouter();
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleRegister = async () => { const handleSignUp = async () => {
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) { if (!email || !password || !firstName || !lastName) {
Alert.alert('Error', 'Please fill in all required fields') Alert.alert("Error", "Please fill in all fields");
return return;
} }
setLoading(true)
try { try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.REGISTER}`, formData) const result = await signUp.create({
emailAddress: email,
password,
firstName,
lastName,
});
if (response.status === 201) { if (result.status === "complete") {
Alert.alert('Success', 'Registration successful! Please login.', [ await setActive({ session: result.createdSessionId });
{ text: 'OK', onPress: () => router.push('/login') } router.replace("/(tabs)/profile");
]) } else {
} Alert.alert("Error", "Sign up failed");
} catch (error: any) {
Alert.alert('Error', error.response?.data?.error || 'Registration failed')
} finally {
setLoading(false)
} }
} catch (err: any) {
Alert.alert("Error", err.message || "Sign up failed");
} }
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.title}>Create Account</Text> <Text style={styles.title}>Sign Up</Text>
<Text style={styles.subtitle}>Join FitAI today</Text>
<View style={styles.form}>
<TextInput <TextInput
style={styles.input}
placeholder="First Name" placeholder="First Name"
value={formData.firstName} value={firstName}
onChangeText={(text) => setFormData({ ...formData, firstName: text })} onChangeText={setFirstName}
style={styles.input}
autoCapitalize="words" autoCapitalize="words"
/> />
<TextInput <TextInput
style={styles.input}
placeholder="Last Name" placeholder="Last Name"
value={formData.lastName} value={lastName}
onChangeText={(text) => setFormData({ ...formData, lastName: text })} onChangeText={setLastName}
style={styles.input}
autoCapitalize="words" autoCapitalize="words"
/> />
<TextInput <TextInput
style={styles.input}
placeholder="Email" placeholder="Email"
value={formData.email} value={email}
onChangeText={(text) => setFormData({ ...formData, email: text })} onChangeText={setEmail}
style={styles.input}
keyboardType="email-address" keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
/> />
<TextInput <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" placeholder="Password"
value={formData.password} value={password}
onChangeText={(text) => setFormData({ ...formData, password: text })} onChangeText={setPassword}
style={styles.input}
secureTextEntry secureTextEntry
/> />
<TouchableOpacity onPress={handleSignUp} style={styles.button}>
<TouchableOpacity <Text style={styles.buttonText}>Sign Up</Text>
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> </TouchableOpacity>
</View> </View>
</View> );
)
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: "center",
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20, padding: 20,
backgroundColor: "#f5f5f5",
}, },
title: { title: {
fontSize: 32, fontSize: 24,
fontWeight: 'bold', fontWeight: "bold",
marginBottom: 8, textAlign: "center",
color: '#333', marginBottom: 20,
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
}, },
input: { input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: "#ddd",
padding: 15,
marginBottom: 15,
borderRadius: 8,
backgroundColor: "white",
}, },
button: { button: {
backgroundColor: '#3b82f6', backgroundColor: "#3b82f6",
paddingVertical: 14, padding: 15,
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: "center",
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
}, },
buttonText: { buttonText: {
color: 'white', color: "white",
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: "600",
}, },
linkButton: { });
alignItems: 'center',
},
linkText: {
color: '#3b82f6',
fontSize: 14,
},
})

View File

@ -1,99 +1,106 @@
import React, { useState } from 'react' import React, { useState } from "react";
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native' import {
import { useRouter } from 'expo-router' View,
import axios from 'axios' Text,
import * as SecureStore from 'expo-secure-store' TextInput,
import { API_BASE_URL, API_ENDPOINTS } from '../config/api' TouchableOpacity,
StyleSheet,
Alert,
ScrollView,
} from "react-native";
import { useRouter } from "expo-router";
import { useUser } from "@clerk/clerk-expo";
import axios from "axios";
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
interface FitnessProfile { interface FitnessProfile {
height: string height: string;
weight: string weight: string;
age: string age: string;
gender: 'male' | 'female' | 'other' gender: "male" | "female" | "other";
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active' activityLevel: "sedentary" | "light" | "moderate" | "active" | "very_active";
fitnessGoals: string[] fitnessGoals: string[];
exerciseHabits: string exerciseHabits: string;
dietHabits: string dietHabits: string;
medicalConditions: string medicalConditions: string;
} }
export default function WelcomeScreen() { export default function WelcomeScreen() {
const [profile, setProfile] = useState<FitnessProfile>({ const [profile, setProfile] = useState<FitnessProfile>({
height: '', height: "",
weight: '', weight: "",
age: '', age: "",
gender: 'male', gender: "male",
activityLevel: 'moderate', activityLevel: "moderate",
fitnessGoals: [], fitnessGoals: [],
exerciseHabits: '', exerciseHabits: "",
dietHabits: '', dietHabits: "",
medicalConditions: '', medicalConditions: "",
}) });
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const router = useRouter() const router = useRouter();
const fitnessGoalsOptions = [ const fitnessGoalsOptions = [
'Weight Loss', "Weight Loss",
'Muscle Gain', "Muscle Gain",
'Improve Endurance', "Improve Endurance",
'Better Flexibility', "Better Flexibility",
'General Fitness', "General Fitness",
'Strength Training', "Strength Training",
'Cardio Health' "Cardio Health",
] ];
const activityLevels = [ const activityLevels = [
{ value: 'sedentary', label: 'Sedentary (little or no exercise)' }, { value: "sedentary", label: "Sedentary (little or no exercise)" },
{ value: 'light', label: 'Light (1-3 days/week)' }, { value: "light", label: "Light (1-3 days/week)" },
{ value: 'moderate', label: 'Moderate (3-5 days/week)' }, { value: "moderate", label: "Moderate (3-5 days/week)" },
{ value: 'active', label: 'Active (6-7 days/week)' }, { value: "active", label: "Active (6-7 days/week)" },
{ value: 'very_active', label: 'Very Active (twice per day)' } { value: "very_active", label: "Very Active (twice per day)" },
] ];
const toggleGoal = (goal: string) => { const toggleGoal = (goal: string) => {
setProfile(prev => ({ setProfile((prev) => ({
...prev, ...prev,
fitnessGoals: prev.fitnessGoals.includes(goal) fitnessGoals: prev.fitnessGoals.includes(goal)
? prev.fitnessGoals.filter(g => g !== goal) ? prev.fitnessGoals.filter((g) => g !== goal)
: [...prev.fitnessGoals, goal] : [...prev.fitnessGoals, goal],
})) }));
} };
const { user } = useUser();
const handleSubmit = async () => { const handleSubmit = async () => {
if (!profile.height || !profile.weight || !profile.age) { if (!profile.height || !profile.weight || !profile.age) {
Alert.alert('Error', 'Please fill in all required fields') Alert.alert("Error", "Please fill in all required fields");
return return;
} }
setLoading(true) setLoading(true);
try { try {
const user = await SecureStore.getItemAsync('user')
if (!user) {
throw new Error('No user found')
}
const userData = JSON.parse(user)
const response = await axios.post( const response = await axios.post(
`${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}`, `${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}`,
{ {
userId: userData.id, userId: user?.id,
...profile ...profile,
} },
) );
if (response.status === 201) { if (response.status === 201) {
Alert.alert('Success', 'Profile completed successfully!', [ Alert.alert("Success", "Profile completed successfully!", [
{ text: 'OK', onPress: () => router.replace('/(tabs)') } { text: "OK", onPress: () => router.replace("/(tabs)") },
]) ]);
} }
} catch (error: any) { } catch (error: any) {
console.log('Profile save error:', error) console.log("Profile save error:", error);
Alert.alert('Error', error.response?.data?.error || 'Failed to save profile. Please try again.') Alert.alert(
"Error",
error.response?.data?.error ||
"Failed to save profile. Please try again.",
);
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
return ( return (
<ScrollView style={styles.container}> <ScrollView style={styles.container}>
@ -110,7 +117,9 @@ export default function WelcomeScreen() {
<TextInput <TextInput
style={styles.input} style={styles.input}
value={profile.height} value={profile.height}
onChangeText={(text) => setProfile({ ...profile, height: text })} onChangeText={(text) =>
setProfile({ ...profile, height: text })
}
keyboardType="numeric" keyboardType="numeric"
placeholder="170" placeholder="170"
/> />
@ -121,7 +130,9 @@ export default function WelcomeScreen() {
<TextInput <TextInput
style={styles.input} style={styles.input}
value={profile.weight} value={profile.weight}
onChangeText={(text) => setProfile({ ...profile, weight: text })} onChangeText={(text) =>
setProfile({ ...profile, weight: text })
}
keyboardType="numeric" keyboardType="numeric"
placeholder="70" placeholder="70"
/> />
@ -143,19 +154,22 @@ export default function WelcomeScreen() {
<View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}> <View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}>
<Text style={styles.label}>Gender</Text> <Text style={styles.label}>Gender</Text>
<View style={styles.genderRow}> <View style={styles.genderRow}>
{(['male', 'female', 'other'] as const).map((gender) => ( {(["male", "female", "other"] as const).map((gender) => (
<TouchableOpacity <TouchableOpacity
key={gender} key={gender}
style={[ style={[
styles.genderButton, styles.genderButton,
profile.gender === gender && styles.genderButtonSelected profile.gender === gender && styles.genderButtonSelected,
]} ]}
onPress={() => setProfile({ ...profile, gender })} onPress={() => setProfile({ ...profile, gender })}
> >
<Text style={[ <Text
style={[
styles.genderButtonText, styles.genderButtonText,
profile.gender === gender && styles.genderButtonTextSelected profile.gender === gender &&
]}> styles.genderButtonTextSelected,
]}
>
{gender.charAt(0).toUpperCase() + gender.slice(1)} {gender.charAt(0).toUpperCase() + gender.slice(1)}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -172,14 +186,20 @@ export default function WelcomeScreen() {
key={level.value} key={level.value}
style={[ style={[
styles.activityOption, styles.activityOption,
profile.activityLevel === level.value && styles.activityOptionSelected profile.activityLevel === level.value &&
styles.activityOptionSelected,
]} ]}
onPress={() => setProfile({ ...profile, activityLevel: level.value as any })} onPress={() =>
setProfile({ ...profile, activityLevel: level.value as any })
}
> >
<Text style={[ <Text
style={[
styles.activityText, styles.activityText,
profile.activityLevel === level.value && styles.activityTextSelected profile.activityLevel === level.value &&
]}> styles.activityTextSelected,
]}
>
{level.label} {level.label}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -195,14 +215,18 @@ export default function WelcomeScreen() {
key={goal} key={goal}
style={[ style={[
styles.goalButton, styles.goalButton,
profile.fitnessGoals.includes(goal) && styles.goalButtonSelected profile.fitnessGoals.includes(goal) &&
styles.goalButtonSelected,
]} ]}
onPress={() => toggleGoal(goal)} onPress={() => toggleGoal(goal)}
> >
<Text style={[ <Text
style={[
styles.goalButtonText, styles.goalButtonText,
profile.fitnessGoals.includes(goal) && styles.goalButtonTextSelected profile.fitnessGoals.includes(goal) &&
]}> styles.goalButtonTextSelected,
]}
>
{goal} {goal}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -215,7 +239,9 @@ export default function WelcomeScreen() {
<TextInput <TextInput
style={[styles.input, styles.textArea]} style={[styles.input, styles.textArea]}
value={profile.exerciseHabits} value={profile.exerciseHabits}
onChangeText={(text) => setProfile({ ...profile, exerciseHabits: text })} onChangeText={(text) =>
setProfile({ ...profile, exerciseHabits: text })
}
placeholder="Describe your current exercise routine..." placeholder="Describe your current exercise routine..."
multiline multiline
numberOfLines={3} numberOfLines={3}
@ -227,7 +253,9 @@ export default function WelcomeScreen() {
<TextInput <TextInput
style={[styles.input, styles.textArea]} style={[styles.input, styles.textArea]}
value={profile.dietHabits} value={profile.dietHabits}
onChangeText={(text) => setProfile({ ...profile, dietHabits: text })} onChangeText={(text) =>
setProfile({ ...profile, dietHabits: text })
}
placeholder="Describe your current eating habits..." placeholder="Describe your current eating habits..."
multiline multiline
numberOfLines={3} numberOfLines={3}
@ -239,7 +267,9 @@ export default function WelcomeScreen() {
<TextInput <TextInput
style={[styles.input, styles.textArea]} style={[styles.input, styles.textArea]}
value={profile.medicalConditions} value={profile.medicalConditions}
onChangeText={(text) => setProfile({ ...profile, medicalConditions: text })} onChangeText={(text) =>
setProfile({ ...profile, medicalConditions: text })
}
placeholder="Any medical conditions we should know about..." placeholder="Any medical conditions we should know about..."
multiline multiline
numberOfLines={3} numberOfLines={3}
@ -252,51 +282,51 @@ export default function WelcomeScreen() {
disabled={loading} disabled={loading}
> >
<Text style={styles.buttonText}> <Text style={styles.buttonText}>
{loading ? 'Saving...' : 'Complete Profile'} {loading ? "Saving..." : "Complete Profile"}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
) );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: "#f5f5f5",
}, },
content: { content: {
padding: 20, padding: 20,
}, },
title: { title: {
fontSize: 28, fontSize: 28,
fontWeight: 'bold', fontWeight: "bold",
marginBottom: 8, marginBottom: 8,
color: '#333', color: "#333",
textAlign: 'center', textAlign: "center",
}, },
subtitle: { subtitle: {
fontSize: 16, fontSize: 16,
color: '#666', color: "#666",
marginBottom: 32, marginBottom: 32,
textAlign: 'center', textAlign: "center",
}, },
section: { section: {
marginBottom: 24, marginBottom: 24,
}, },
sectionTitle: { sectionTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: "600",
marginBottom: 12, marginBottom: 12,
color: '#333', color: "#333",
}, },
sectionSubtitle: { sectionSubtitle: {
fontSize: 14, fontSize: 14,
color: '#666', color: "#666",
marginBottom: 12, marginBottom: 12,
}, },
row: { row: {
flexDirection: 'row', flexDirection: "row",
marginBottom: 16, marginBottom: 16,
}, },
inputContainer: { inputContainer: {
@ -304,105 +334,105 @@ const styles = StyleSheet.create({
}, },
label: { label: {
fontSize: 14, fontSize: 14,
fontWeight: '500', fontWeight: "500",
marginBottom: 6, marginBottom: 6,
color: '#333', color: "#333",
}, },
input: { input: {
backgroundColor: 'white', backgroundColor: "white",
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
borderRadius: 8, borderRadius: 8,
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: "#ddd",
fontSize: 16, fontSize: 16,
}, },
textArea: { textArea: {
height: 80, height: 80,
textAlignVertical: 'top', textAlignVertical: "top",
}, },
genderRow: { genderRow: {
flexDirection: 'row', flexDirection: "row",
}, },
genderButton: { genderButton: {
flex: 1, flex: 1,
paddingVertical: 12, paddingVertical: 12,
paddingHorizontal: 8, paddingHorizontal: 8,
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: "#ddd",
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: "center",
marginRight: 4, marginRight: 4,
}, },
genderButtonSelected: { genderButtonSelected: {
backgroundColor: '#3b82f6', backgroundColor: "#3b82f6",
borderColor: '#3b82f6', borderColor: "#3b82f6",
}, },
genderButtonText: { genderButtonText: {
fontSize: 12, fontSize: 12,
color: '#666', color: "#666",
}, },
genderButtonTextSelected: { genderButtonTextSelected: {
color: 'white', color: "white",
}, },
activityOption: { activityOption: {
paddingVertical: 12, paddingVertical: 12,
paddingHorizontal: 16, paddingHorizontal: 16,
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: "#ddd",
borderRadius: 8, borderRadius: 8,
marginBottom: 8, marginBottom: 8,
backgroundColor: 'white', backgroundColor: "white",
}, },
activityOptionSelected: { activityOptionSelected: {
backgroundColor: '#3b82f6', backgroundColor: "#3b82f6",
borderColor: '#3b82f6', borderColor: "#3b82f6",
}, },
activityText: { activityText: {
fontSize: 14, fontSize: 14,
color: '#333', color: "#333",
}, },
activityTextSelected: { activityTextSelected: {
color: 'white', color: "white",
}, },
goalsContainer: { goalsContainer: {
flexDirection: 'row', flexDirection: "row",
flexWrap: 'wrap', flexWrap: "wrap",
marginHorizontal: -4, marginHorizontal: -4,
}, },
goalButton: { goalButton: {
backgroundColor: 'white', backgroundColor: "white",
borderWidth: 1, borderWidth: 1,
borderColor: '#ddd', borderColor: "#ddd",
borderRadius: 20, borderRadius: 20,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 8, paddingVertical: 8,
margin: 4, margin: 4,
}, },
goalButtonSelected: { goalButtonSelected: {
backgroundColor: '#3b82f6', backgroundColor: "#3b82f6",
borderColor: '#3b82f6', borderColor: "#3b82f6",
}, },
goalButtonText: { goalButtonText: {
fontSize: 12, fontSize: 12,
color: '#666', color: "#666",
}, },
goalButtonTextSelected: { goalButtonTextSelected: {
color: 'white', color: "white",
}, },
button: { button: {
backgroundColor: '#3b82f6', backgroundColor: "#3b82f6",
paddingVertical: 16, paddingVertical: 16,
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: "center",
marginTop: 16, marginTop: 16,
}, },
buttonDisabled: { buttonDisabled: {
backgroundColor: '#9ca3af', backgroundColor: "#9ca3af",
}, },
buttonText: { buttonText: {
color: 'white', color: "white",
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: "600",
}, },
}) });

View File

@ -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
}

View File

@ -1,16 +1,16 @@
import { useAuth } from '@/contexts/AuthContext' import { useUser } from "@clerk/clerk-expo";
import { useRouter } from 'expo-router' import { useRouter } from "expo-router";
import { useEffect } from 'react' import { useEffect } from "react";
export function useRequireAuth() { export function useRequireAuth() {
const { user, isLoading } = useAuth() const { user, isLoaded, isSignedIn } = useUser();
const router = useRouter() const router = useRouter();
useEffect(() => { useEffect(() => {
if (!isLoading && !user) { if (isLoaded && !isSignedIn) {
router.replace('/login') router.replace("/login");
} }
}, [user, isLoading, router]) }, [isSignedIn, isLoaded, router]);
return { user, isLoading } return { user, isLoading: !isLoaded };
} }