Compare commits

..
47 Commits
Author SHA1 Message Date
ace bb7211f230 feat: add CardTitle, CardDescription, CardFooter components 2026-03-14 00:03:07 +01:00
ace 94beb7da78 test commit 2025-12-15 14:19:25 +01:00
ace 1f7b90b816 fix: add ignoreDeprecations to mobile tsconfig to suppress baseUrl warning 2025-12-15 13:13:31 +01:00
ace 04856021b9 feat: add refresh button to geofencing status card 2025-12-15 13:05:21 +01:00
ace c238e18766 fix: remove unsupported gap style from GeofenceStatus 2025-12-15 12:31:55 +01:00
ace 0c272502f8 feat: add simple geofence status card to attendance screen - demo mode 2025-12-15 12:25:56 +01:00
ace 231ad44cc4 fix: tsconfig ignoreDeprecations and GeofenceStatus component - use GYM_LOCATION constant 2025-12-15 12:19:27 +01:00
ace 2f79d1547f feat: enhance UI with metallic gradient cards - admin and mobile consistent color schemes 2025-12-15 12:10:44 +01:00
ace 337351dd06 fix: add ignoreDeprecations to tsconfig.json to silence baseUrl deprecation warning 2025-12-15 10:38:32 +01:00
ace fe3f93fb9f style: increase NextForm logo size across all components 2025-12-11 14:49:15 +01:00
ace 3c810003c5 feat: integrate NextForm PNG logo across admin and mobile applications 2025-12-11 14:48:12 +01:00
ace 6207949e2d fix: use nextform.png logo across all components 2025-12-11 14:39:15 +01:00
ace 76b9ee24c5 style: update sidebar and dashboard with NextForm branding 2025-12-11 14:28:10 +01:00
ace ad6fdc2226 feat: add NextForm logo to admin navigation, dashboard header and mobile home screen 2025-12-11 14:24:37 +01:00
ace 8c2a3daee0 style: apply kameleon gradient transitions to analytics cards - all metrics and charts with via-colors 2025-12-11 14:16:41 +01:00
ace 19ece90c6f style: adjust card sizing to balanced medium - between original and enlarged 2025-12-11 14:15:28 +01:00
ace b9efedbb88 style: add chameleon gradient transitions with via colors for smooth color blending 2025-12-11 14:13:10 +01:00
ace 204e6edca5 style: apply strong gradient colors to stats cards instead of light backgrounds 2025-12-11 14:11:22 +01:00
ace 918357c00c fix: add missing CardTitle, CardDescription and CardFooter exports to Card component 2025-12-11 14:10:33 +01:00
ace 4fbe8399df fix: add card.ts compatibility layer for lowercase Card imports 2025-12-11 14:09:37 +01:00
ace 61bad8d30c fix: add button.ts compatibility layer and fix all Button imports to use lowercase 2025-12-11 14:08:58 +01:00
ace 966bcb084d style: implement dark gradient metric cards with high contrast white text and colored labels 2025-12-11 14:01:32 +01:00
ace 71a05c51bf fix: update mobile metric cards with proper 500-range vibrant colors matching admin dashboard 2025-12-11 14:00:03 +01:00
ace e4eadc858c style: strengthen performance metrics colors with darker gradients and brighter white text 2025-12-11 13:56:18 +01:00
ace 0024510fdb feat: add performance metrics dashboard to mobile home screen with gradient cards 2025-12-11 13:54:01 +01:00
ace c9fbab3bbb style: revert background to light theme and update card colors to match sidebar palette 2025-12-11 13:51:36 +01:00
ace 35d64ef3c0 style: apply bold modern colors throughout dashboard - dark backgrounds, vibrant gradients, white text 2025-12-11 13:48:30 +01:00
ace 6dc90f1745 feat: reorganize dashboard layout with UserManagement full-width and horizontal analytics charts 2025-12-11 13:44:02 +01:00
ace 02452c11e6 fix: simplify AnalyticsDashboard layout to prevent text overflow and improve stability 2025-12-11 13:41:57 +01:00
ace dd78602dd6 style: improve UserManagement layout with flex-wrap and better organization 2025-12-11 13:39:42 +01:00
ace 0a55438bce fix: remove extra closing brace in StatsCard 2025-12-11 13:38:03 +01:00
ace 51df7f57ec style: compact stats cards with better colors and layout 2025-12-11 13:37:01 +01:00
ace feacef2a81 fix: remove all duplicate closing tags in StatsCard 2025-12-11 13:35:37 +01:00
ace 77546a5017 fix: remove duplicate code in StatsCard 2025-12-11 13:34:40 +01:00
ace d38f0a4cc2 style: enhance card colors and improve typography hierarchy 2025-12-11 13:32:35 +01:00
ace b6764b5ed6 fix: remove duplicate closing brackets in Sidebar 2025-12-11 13:29:36 +01:00
ace e84d0f5f8f feat: modernize admin dashboard with premium sports design 2025-12-11 13:27:57 +01:00
ace fda66a7703 fix: completely disable camera/barcode on web platform 2025-12-11 13:23:56 +01:00
ace 2d9fbd186b fix: disable barcode scanner on web platform 2025-12-11 13:19:09 +01:00
ace bbd0cfde9c fix: add platform check for WebBrowser warmUpAsync on web 2025-12-11 13:15:33 +01:00
ace 16e22f330a fix: update route params to Promise type for Next.js 16 compatibility 2025-12-11 13:10:51 +01:00
ace efa2f2a8d8 fix: correct Button import paths to use lowercase button 2025-12-11 13:04:50 +01:00
dimitar 62e2255b5e fix 2025-12-01 23:17:40 +01:00
dimitar d7d270510f c 2025-12-01 21:10:04 +01:00
dimitar a5c297b911 ups 2025-12-01 21:09:35 +01:00
dimitar 0011c9d4e5 currently check in implemented 2025-12-01 20:07:48 +01:00
dimitar db0d2cf215 scan food
with mock db, real db[openfoodfacts] to be implemented
2025-12-01 19:49:10 +01:00
51 changed files with 2104 additions and 483 deletions

No files matched your search

Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2FB7E8;stop-opacity:1" />
<stop offset="50%" style="stop-color:#0B9FD0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0B7FB3;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main arrow shape -->
<path d="M 420 150 L 540 150 L 420 300 Z" fill="url(#grad1)" opacity="0.95"/>
<path d="M 180 150 L 300 300 L 180 300 Z" fill="url(#grad1)" opacity="0.85"/>
<!-- Dumbbell - Left -->
<g transform="translate(200, 250)">
<!-- Left disk -->
<circle cx="0" cy="0" r="35" fill="white" opacity="0.95"/>
<!-- Bar left -->
<rect x="35" y="-15" width="80" height="30" fill="white" rx="4"/>
<!-- Right disk -->
<circle cx="115" cy="0" r="35" fill="white" opacity="0.95"/>
<!-- Grip lines -->
<line x1="45" y1="-35" x2="45" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="65" y1="-35" x2="65" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="85" y1="-35" x2="85" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="105" y1="-35" x2="105" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2FB7E8;stop-opacity:1" />
<stop offset="50%" style="stop-color:#0B9FD0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0B7FB3;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main arrow shape -->
<path d="M 420 150 L 540 150 L 420 300 Z" fill="url(#grad1)" opacity="0.95"/>
<path d="M 180 150 L 300 300 L 180 300 Z" fill="url(#grad1)" opacity="0.85"/>
<!-- Dumbbell - Left -->
<g transform="translate(200, 250)">
<!-- Left disk -->
<circle cx="0" cy="0" r="35" fill="white" opacity="0.95"/>
<!-- Bar left -->
<rect x="35" y="-15" width="80" height="30" fill="white" rx="4"/>
<!-- Right disk -->
<circle cx="115" cy="0" r="35" fill="white" opacity="0.95"/>
<!-- Grip lines -->
<line x1="45" y1="-35" x2="45" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="65" y1="-35" x2="65" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="85" y1="-35" x2="85" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="105" y1="-35" x2="105" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
PNG - NextForm Logo
@@ -13,21 +13,8 @@ export async function POST(req: Request) {
// Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db)
let client = await db.getClientByUserId(userId)
if (!client) {
// Auto-create client profile if it doesn't exist
console.log('Client profile not found, creating new one for user:', userId)
client = await db.createClient({
userId,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
})
}
// Check if already checked in
const activeCheckIn = await db.getActiveCheckIn(client.id)
const activeCheckIn = await db.getActiveCheckIn(userId)
if (activeCheckIn) {
return new NextResponse('Already checked in', { status: 400 })
}
@@ -35,7 +22,7 @@ export async function POST(req: Request) {
const body = await req.json()
const { type = 'gym', notes } = body
const attendance = await db.checkIn(client.id, type, notes)
const attendance = await db.checkIn(userId, type, notes)
return NextResponse.json(attendance)
} catch (error) {
console.error('Check-in error:', error)
@@ -8,13 +8,8 @@ export async function POST(req: Request) {
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
const db = await getDatabase()
const client = await db.getClientByUserId(userId)
if (!client) {
return new NextResponse('Client profile not found', { status: 404 })
}
const activeCheckIn = await db.getActiveCheckIn(client.id)
const activeCheckIn = await db.getActiveCheckIn(userId)
if (!activeCheckIn) {
return new NextResponse('No active check-in found', { status: 404 })
}
@@ -22,20 +22,7 @@ export async function GET(req: Request) {
// Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db)
let client = await db.getClientByUserId(userId)
if (!client) {
// Auto-create client profile if it doesn't exist
console.log('Client profile not found, creating new one for user:', userId)
client = await db.createClient({
userId,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
})
}
const history = await db.getAttendanceHistory(client.id)
const history = await db.getAttendanceHistory(userId)
return NextResponse.json(history)
} catch (error) {
console.error('History error:', error)
@@ -5,7 +5,7 @@ import { getDatabase } from '@/lib/database';
// POST - Mark goal as complete
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await auth();
@@ -5,7 +5,7 @@ import { getDatabase } from '@/lib/database';
// GET - Get specific goal
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await auth();
@@ -40,7 +40,7 @@ export async function GET(
// PUT - Update goal
export async function PUT(
req: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await auth();
@@ -82,7 +82,7 @@ export async function PUT(
// DELETE - Delete goal
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await auth();
+46 -1
View File
@@ -19,7 +19,52 @@ export async function GET(request: NextRequest) {
users.map(async (user) => {
const { password: _, ...userWithoutPassword } = user;
const client = await db.getClientByUserId(user.id);
return { ...userWithoutPassword, client };
// Get active check-in status and statistics for ALL users
let isCheckedIn = false;
let checkInTime = null;
let lastCheckInTime = null;
let checkInsThisWeek = 0;
let checkInsThisMonth = 0;
// Query attendance by userId (works for all user types now)
const activeCheckIn = await db.getActiveCheckIn(user.id);
if (activeCheckIn) {
isCheckedIn = true;
checkInTime = activeCheckIn.checkInTime;
}
// Get attendance history for statistics
const attendanceHistory = await db.getAttendanceHistory(user.id);
if (attendanceHistory.length > 0) {
// Last check-in is the most recent attendance record
lastCheckInTime = attendanceHistory[0].checkInTime;
// Calculate check-ins in last 7 days
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
checkInsThisWeek = attendanceHistory.filter(
a => new Date(a.checkInTime) >= weekAgo
).length;
// Calculate check-ins in last 30 days
const monthAgo = new Date();
monthAgo.setDate(monthAgo.getDate() - 30);
checkInsThisMonth = attendanceHistory.filter(
a => new Date(a.checkInTime) >= monthAgo
).length;
}
return {
...userWithoutPassword,
client,
isCheckedIn,
checkInTime,
lastCheckInTime,
checkInsThisWeek,
checkInsThisMonth
};
}),
);
+1 -1
View File
@@ -32,7 +32,7 @@
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--radius: 0.75rem;
}
.dark {
+27 -4
View File
@@ -25,11 +25,34 @@ export default function RootLayout({
return (
<ClerkProvider>
<html lang="en">
<body className={inter.className}>
<div className="flex min-h-screen bg-slate-50">
<body className={`${inter.className} bg-gradient-to-br from-slate-50 via-blue-50 to-slate-100 min-h-screen`}>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 ml-64 p-8">
{children}
<main className="flex-1 ml-64">
<div className="sticky top-0 z-40 backdrop-blur-xl bg-white/75 border-b border-slate-200/50 shadow-sm">
<div className="px-8 py-4 flex items-center justify-between max-w-7xl mx-auto">
<div>
<h1 className="text-sm font-semibold text-gray-600 uppercase tracking-wide">FitAI Pro</h1>
</div>
<div className="flex items-center gap-4">
<SignedIn>
<UserButton
appearance={{
elements: {
avatarBox: "w-10 h-10 rounded-full ring-2 ring-blue-200"
}
}}
/>
</SignedIn>
<SignedOut>
<SignInButton mode="modal" />
</SignedOut>
</div>
</div>
</div>
<div className="p-8">
{children}
</div>
</main>
</div>
</body>
+77 -48
View File
@@ -46,56 +46,85 @@ export default function Home() {
};
return (
<div className="space-y-8">
<div>
<h2 className="text-3xl font-bold text-slate-900">Dashboard</h2>
<p className="text-slate-500 mt-2">Welcome back, here's what's happening today.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total Users"
value={loading ? "..." : stats.totalUsers}
change="+12%" // Placeholder for now as we don't track historical growth yet
trend="up"
icon={Users}
color="blue"
/>
<StatsCard
title="Active Clients"
value={loading ? "..." : stats.activeClients}
change="+5%"
trend="up"
icon={CalendarCheck}
color="green"
/>
<StatsCard
title="Revenue"
value={loading ? "..." : formatCurrency(stats.totalRevenue)}
change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}%`}
trend={stats.revenueGrowth >= 0 ? "up" : "down"}
icon={CreditCard}
color="purple"
/>
<StatsCard
title="Growth"
value="24%" // Placeholder
change="-2%"
trend="down"
icon={TrendingUp}
color="orange"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 className="text-xl font-bold text-slate-900 mb-6">Recent Activity</h3>
<UserManagement />
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-slate-100">
<div className="space-y-8 max-w-7xl">
{/* Hero Section */}
<div className="pt-8 pb-4">
<div className="space-y-3">
<div className="flex items-center gap-4">
<img
src="/nextform-logo.png"
alt="NextForm"
className="h-20 w-20 object-contain"
/>
<div className="flex items-center gap-3">
<div className="h-12 w-1 bg-gradient-to-b from-blue-600 to-cyan-600 rounded-full"></div>
<h1 className="text-5xl font-black bg-gradient-to-r from-blue-600 via-blue-700 to-cyan-600 bg-clip-text text-transparent">
NextForm
</h1>
</div>
</div>
<p className="text-lg text-gray-600 ml-4">Performance metrics & athlete insights</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 className="text-xl font-bold text-slate-900 mb-6">Quick Analytics</h3>
<AnalyticsDashboard />
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
<StatsCard
title="Total Users"
value={loading ? "..." : stats.totalUsers}
change="+12%" // Placeholder for now as we don't track historical growth yet
trend="up"
icon={Users}
color="blue"
/>
<StatsCard
title="Active Clients"
value={loading ? "..." : stats.activeClients}
change="+5%"
trend="up"
icon={CalendarCheck}
color="green"
/>
<StatsCard
title="Revenue"
value={loading ? "..." : formatCurrency(stats.totalRevenue)}
change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}%`}
trend={stats.revenueGrowth >= 0 ? "up" : "down"}
icon={CreditCard}
color="purple"
/>
<StatsCard
title="Growth"
value="24%" // Placeholder
change="-2%"
trend="down"
icon={TrendingUp}
color="orange"
/>
</div>
{/* Main Content Grid */}
<div className="space-y-6 pb-12">
{/* User Management - Full Width */}
<div className="bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50 rounded-2xl shadow-lg border border-slate-200/50 p-7 hover:shadow-2xl transition-all duration-300 backdrop-blur-sm">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-slate-900">Active Athletes</h2>
<p className="text-sm text-gray-500 mt-1">Manage and monitor your fitness clients</p>
</div>
<UserManagement />
</div>
</div>
{/* Analytics - 3 Columns Horizontal Layout */}
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold text-slate-900">Analytics</h2>
<p className="text-sm text-gray-500 mt-1">Performance metrics and insights</p>
</div>
<AnalyticsDashboard />
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs";
import { Button } from "@/components/ui/Button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface UserProfile {
+10 -3
View File
@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
import { Home, Users, BarChart3, User, Brain } from "lucide-react";
import { SignedIn, UserButton } from "@clerk/nextjs";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
import { Button } from "@/components/ui/button";
interface NavItem {
href: string;
@@ -52,9 +52,16 @@ export function Navigation(): ReactElement {
{/* Logo */}
<Link
href="/"
className="text-xl font-bold text-[#FF0000] hover:text-[#00FF00] transition-colors"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
FitAI
<img
src="/nextform-logo.png"
alt="NextForm"
className="h-10 w-10 object-contain"
/>
<span className="text-lg font-bold bg-gradient-to-r from-blue-600 to-blue-800 bg-clip-text text-transparent">
NextForm
</span>
</Link>
{/* Navigation Items */}
@@ -67,74 +67,75 @@ export function AnalyticsDashboard() {
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Loading analytics...</div>
<div className="text-center">
<div className="inline-block animate-spin">⚡</div>
<p className="text-gray-600 mt-2 text-sm">Loading analytics...</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Analytics Dashboard</h2>
<div className="space-y-4">
{/* Key Metrics Cards - 3 columns */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="bg-gradient-to-br from-blue-700 via-cyan-500 to-blue-600 rounded-xl p-5 border-0 shadow-lg hover:shadow-2xl transition-all text-white">
<p className="text-xs uppercase tracking-wide font-bold text-white mb-2">Total Athletes</p>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-black text-white">{totalUsers}</div>
<span className="text-xs text-white font-semibold">active</span>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardContent>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{totalUsers}</div>
<div className="text-gray-600">Total Users</div>
</div>
</CardContent>
</Card>
<div className="bg-gradient-to-br from-amber-700 via-yellow-500 to-amber-600 rounded-xl p-5 border-0 shadow-lg hover:shadow-2xl transition-all text-white">
<p className="text-xs uppercase tracking-wide font-bold text-white mb-2">Total Revenue</p>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-black text-white">${totalRevenue.toLocaleString()}</div>
<span className="text-xs text-white font-semibold">ytd</span>
</div>
</div>
<Card>
<CardContent>
<div className="text-center">
<div className="text-3xl font-bold text-green-600">${totalRevenue.toLocaleString()}</div>
<div className="text-gray-600">Total Revenue</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">{activeMembers}</div>
<div className="text-gray-600">Active Members</div>
</div>
</CardContent>
</Card>
<div className="bg-gradient-to-br from-pink-700 via-fuchsia-500 to-pink-600 rounded-xl p-5 border-0 shadow-lg hover:shadow-2xl transition-all text-white">
<p className="text-xs uppercase tracking-wide font-bold text-white mb-2">Active Members</p>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-black text-white">{activeMembers}</div>
<span className="text-xs text-white font-semibold">members</span>
</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">User Growth</h3>
</CardHeader>
<CardContent>
{/* Charts - 3 Columns Horizontal */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="bg-gradient-to-br from-blue-700 via-cyan-500 to-blue-600 rounded-xl shadow-lg border-0 p-5 hover:shadow-2xl transition-all">
<div className="mb-3">
<h3 className="text-sm font-bold text-white">User Growth Trend</h3>
<p className="text-xs text-blue-100">Last 6 months performance</p>
</div>
<div className="h-48 overflow-auto">
<UserGrowthChart data={userGrowthData} />
</CardContent>
</Card>
</div>
</div>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Membership Distribution</h3>
</CardHeader>
<CardContent>
<div className="bg-gradient-to-br from-amber-700 via-yellow-500 to-amber-600 rounded-xl shadow-lg border-0 p-5 hover:shadow-2xl transition-all">
<div className="mb-3">
<h3 className="text-sm font-bold text-white">Membership Mix</h3>
<p className="text-xs text-amber-100">Distribution breakdown</p>
</div>
<div className="h-48 overflow-auto">
<MembershipDistributionChart data={membershipData} />
</CardContent>
</Card>
</div>
</div>
</div>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Monthly Revenue</h3>
</CardHeader>
<CardContent>
<RevenueChart data={revenueData} />
</CardContent>
</Card>
<div className="bg-gradient-to-br from-pink-700 via-fuchsia-500 to-pink-600 rounded-xl shadow-lg border-0 p-5 hover:shadow-2xl transition-all">
<div className="mb-3">
<h3 className="text-sm font-bold text-white">Revenue Stream</h3>
<p className="text-xs text-pink-100">Monthly earnings</p>
</div>
<div className="h-48 overflow-auto">
<RevenueChart data={revenueData} />
</div>
</div>
</div>
</div>
)
}
+32 -17
View File
@@ -65,14 +65,25 @@ export function Sidebar() {
];
return (
<aside className="w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800">
<div className="p-6 border-b border-slate-800">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
FitAI Admin
</h1>
<aside className="w-64 bg-gradient-to-b from-slate-900 via-slate-900 to-slate-950 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800/50 shadow-2xl">
{/* Logo Section */}
<div className="p-6 border-b border-slate-800/50">
<div className="flex items-center gap-3 justify-center">
<img
src="/nextform-logo.png"
alt="NextForm"
className="h-16 w-16 object-contain"
/>
<div>
<h1 className="text-lg font-black bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-300 bg-clip-text text-transparent">
NextForm
</h1>
</div>
</div>
</div>
<nav className="flex-1 p-4 space-y-2">
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
@@ -81,26 +92,30 @@ export function Sidebar() {
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
: "text-slate-400 hover:bg-slate-800 hover:text-white"}`}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group relative overflow-hidden ${isActive
? "bg-gradient-to-r from-blue-600 to-cyan-600 text-white shadow-lg shadow-blue-900/30"
: "text-slate-400 hover:bg-slate-800/40 hover:text-white"}`}
>
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
<span className="font-medium">{label}</span>
{isActive && <div className="absolute inset-0 bg-white/10 blur-xl"></div>}
<Icon size={20} className={`${isActive ? "text-white" : "text-slate-500 group-hover:text-white"} relative z-10`} />
<span className="font-semibold relative z-10">{label}</span>
</Link>
);
})}
</nav>
<div className="p-4 border-t border-slate-800">
<div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-slate-800/50">
<UserButton afterSignOutUrl="/" />
{/* User Section */}
<div className="p-4 border-t border-slate-800/50 bg-slate-800/20">
<div className="flex items-center gap-3 px-3 py-3 rounded-xl bg-slate-800/50 hover:bg-slate-800/70 transition-colors">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 flex-shrink-0 overflow-hidden">
<UserButton afterSignOutUrl="/" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{user?.fullName || "Admin User"}
<p className="text-sm font-semibold text-white truncate">
{user?.fullName || "Admin"}
</p>
<p className="text-xs text-slate-400 truncate">
{user?.primaryEmailAddress?.emailAddress}
{user?.emailAddresses[0]?.emailAddress || "admin@fitai.com"}
</p>
</div>
</div>
+45 -22
View File
@@ -12,38 +12,61 @@ interface StatsCardProps {
export function StatsCard({ title, value, change, trend, icon: Icon, color = "blue" }: StatsCardProps) {
const colorStyles = {
blue: "bg-blue-50 text-blue-600",
green: "bg-green-50 text-green-600",
purple: "bg-purple-50 text-purple-600",
orange: "bg-orange-50 text-orange-600",
blue: {
bg: "from-blue-700 via-cyan-500 to-blue-600",
text: "text-white",
light: "from-blue-50 to-cyan-50",
badge: "bg-blue-200/60 text-blue-900 backdrop-blur-sm",
icon: "bg-white/20 text-white backdrop-blur-sm",
},
green: {
bg: "from-pink-700 via-fuchsia-500 to-pink-600",
text: "text-white",
light: "from-pink-50 to-rose-50",
badge: "bg-pink-200/60 text-pink-900 backdrop-blur-sm",
icon: "bg-white/20 text-white backdrop-blur-sm",
},
purple: {
bg: "from-amber-700 via-yellow-500 to-amber-600",
text: "text-white",
light: "from-amber-50 to-yellow-50",
badge: "bg-amber-200/60 text-amber-900 backdrop-blur-sm",
icon: "bg-white/20 text-white backdrop-blur-sm",
},
orange: {
bg: "from-emerald-700 via-teal-500 to-emerald-600",
text: "text-white",
light: "from-emerald-50 to-teal-50",
badge: "bg-emerald-200/60 text-emerald-900 backdrop-blur-sm",
icon: "bg-white/20 text-white backdrop-blur-sm",
},
};
const styles = colorStyles[color];
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
<Card className={`bg-gradient-to-br ${styles.bg} border-0 shadow-2xl hover:shadow-2xl transition-all duration-500 overflow-hidden group h-full relative before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/10 before:to-transparent before:pointer-events-none`}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2.5 pt-5 px-5">
<CardTitle className={`text-xs font-bold uppercase tracking-wider ${styles.text} leading-tight max-w-[70%]`}>
{title}
</CardTitle>
<div className={`p-2 rounded-lg ${colorStyles[color]}`}>
<Icon size={16} />
<div className={`p-1.5 rounded-lg ${styles.icon} flex-shrink-0`}>
<Icon size={16} strokeWidth={2} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<CardContent className="px-5 pb-5 space-y-2.5">
<div className={`text-2xl font-black ${styles.text}`}>
{value}
</div>
{change && (
<p className="text-xs text-muted-foreground mt-1">
<div className="flex items-center gap-1">
<span
className={`font-medium ${trend === "up"
? "text-green-600"
: trend === "down"
? "text-red-600"
: "text-slate-600"
}`}
className={`inline-flex items-center gap-0.5 font-bold px-2 py-0.5 rounded-md text-xs tracking-wide ${styles.badge}`}
>
{change}
</span>{" "}
vs last month
</p>
{trend === "up" ? "↑" : trend === "down" ? "↓" : "→"} {change}
</span>
<span className="text-xs text-gray-600">vs month</span>
</div>
)}
</CardContent>
</Card>
+2
View File
@@ -0,0 +1,2 @@
// Re-export Button component with lowercase filename for compatibility
export { Button } from './Button';
+19 -52
View File
@@ -1,56 +1,23 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import React from 'react'
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;
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary'
children: React.ReactNode
}
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 function Button({ variant = 'primary', children, className = '', ...props }: ButtonProps) {
const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors'
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
}
export { Button, buttonVariants };
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
{...props}
>
{children}
</button>
)
}
+2
View File
@@ -0,0 +1,2 @@
// Re-export Card components with lowercase filename for compatibility
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card';
+70 -68
View File
@@ -1,76 +1,78 @@
import * as React from "react"
import React from 'react'
import { cn } from "@/lib/utils"
interface CardProps {
children: React.ReactNode
className?: string
}
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
export function Card({ children, className = '' }: CardProps) {
return (
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
{children}
</div>
)
}
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
export function CardHeader({ children, className = '' }: CardProps) {
return (
<div className={`mb-4 ${className}`}>
{children}
</div>
)
}
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
export function CardContent({ children, className = '' }: CardProps) {
return (
<div className={className}>
{children}
</div>
)
}
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
export function CardTitle({ children, className = '' }: CardProps) {
return (
<h2 className={`text-lg font-semibold ${className}`}>
{children}
</h2>
)
}
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
export function CardDescription({ children, className = '' }: CardProps) {
return (
<p className={`text-sm text-gray-600 ${className}`}>
{children}
</p>
)
}
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export function CardFooter({ children, className = '' }: CardProps) {
return (
<div className={`mt-4 ${className}`}>
{children}
</div>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export function CardTitle({ children, className = '' }: CardProps) {
return (
<h2 className={`text-lg font-semibold ${className}`}>
{children}
</h2>
)
}
export function CardDescription({ children, className = '' }: CardProps) {
return (
<p className={`text-sm text-gray-600 ${className}`}>
{children}
</p>
)
}
export function CardFooter({ children, className = '' }: CardProps) {
return (
<div className={`mt-4 ${className}`}>
{children}
</div>
)
}
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/Button";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface Recommendation {
+85 -21
View File
@@ -9,6 +9,20 @@ import { formatDate } from "@/lib/utils";
ModuleRegistry.registerModules([AllCommunityModule]);
function getTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
interface User {
id: string;
email: string;
@@ -17,6 +31,8 @@ interface User {
role: string;
phone?: string;
createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
client?: {
id: string;
membershipType: string;
@@ -152,6 +168,27 @@ export function UserGrid({
},
minWidth: 120,
},
{
headerName: "Currently Checked In",
valueGetter: (params) => params.data?.isCheckedIn,
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.data?.isCheckedIn) {
return <span className="text-gray-400">—</span>;
}
const checkInTime = params.data.checkInTime ? new Date(params.data.checkInTime) : null;
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : '';
return (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
✓ Checked In {timeAgo && `(${timeAgo})`}
</span>
);
},
minWidth: 180,
},
{
headerName: "Join Date",
valueGetter: (params) =>
@@ -228,45 +265,72 @@ export function UserGrid({
};
return (
<div>
<div className="flex justify-between items-center mb-4">
<input
type="text"
placeholder="Search users..."
className="border border-gray-300 rounded px-4 py-2"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="flex gap-2">
<div className="space-y-4">
{/* Search and Actions Bar */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-gradient-to-r from-slate-50 to-blue-50 p-4 rounded-xl border border-slate-200">
<div className="w-full sm:w-auto flex-1">
<div className="relative">
<svg className="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search athletes..."
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white shadow-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button
className="bg-green-500 text-white px-4 py-2 rounded disabled:opacity-50"
className="flex-1 sm:flex-none bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed font-semibold transition-all shadow-md hover:shadow-lg"
onClick={handleEdit}
disabled={selectedUsers.length !== 1}
>
Edit User
✏️ Edit
</button>
<button
className="bg-red-500 text-white px-4 py-2 rounded disabled:opacity-50"
className="flex-1 sm:flex-none bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed font-semibold transition-all shadow-md hover:shadow-lg"
onClick={handleDelete}
disabled={selectedUsers.length !== 1}
>
Delete User
🗑️ Delete
</button>
<button
className="bg-yellow-500 text-white px-4 py-2 rounded disabled:opacity-50"
className="flex-1 sm:flex-none bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed font-semibold transition-all shadow-md hover:shadow-lg"
onClick={handleBulkDelete}
disabled={selectedUsers.length === 0}
>
Bulk Delete
⚡ Bulk
</button>
</div>
</div>
<div
className="ag-theme-alpine"
style={{ height: "600px", width: "100%" }}
>
<AgGridReact<User> {...gridOptions} ref={gridRef} />
{/* Table */}
<div className="bg-white rounded-2xl shadow-lg border border-slate-200 overflow-hidden">
<div
className="ag-theme-alpine"
style={{ height: "600px", width: "100%" }}
>
<AgGridReact<User> {...gridOptions} ref={gridRef} />
</div>
</div>
{/* Selected Count */}
{selectedUsers.length > 0 && (
<div className="flex items-center justify-between bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-200/50 px-4 py-3 rounded-lg">
<p className="text-sm font-semibold text-blue-700">
{selectedUsers.length} athlete{selectedUsers.length !== 1 ? 's' : ''} selected
</p>
<button
onClick={() => setSelectedUsers([])}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Clear selection
</button>
</div>
)}
</div>
);
}
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { UserGrid } from "@/components/users/UserGrid";
import { Button } from "@/components/ui/Button";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface User {
@@ -13,6 +13,11 @@ interface User {
role: string;
phone?: string;
createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
lastCheckInTime?: Date;
checkInsThisWeek?: number;
checkInsThisMonth?: number;
client?: {
id: string;
membershipType: string;
@@ -200,92 +205,106 @@ export function UserManagement() {
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">User Management</h2>
<div className="flex gap-2">
<Button
variant={filter === "all" ? "primary" : "secondary"}
onClick={() => setFilter("all")}
>
All Users
</Button>
<Button
variant="secondary"
onClick={() => selectedUser && handleEditUser(selectedUser)}
disabled={!selectedUser}
>
Edit User
</Button>
<Button
variant="secondary"
onClick={() => {
setEditForm({
firstName: "",
lastName: "",
email: "",
role: "client",
phone: "",
});
setSelectedUser(null);
setIsEditing(true);
}}
>
Invite User
</Button>
<Button
variant="secondary"
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
disabled={!selectedUser}
>
Delete User
</Button>
<Button
variant={filter === "client" ? "primary" : "secondary"}
onClick={() => setFilter("client")}
>
Clients
</Button>
<Button
variant={filter === "trainer" ? "primary" : "secondary"}
onClick={() => setFilter("trainer")}
>
Trainers
</Button>
<Button
variant={filter === "admin" ? "primary" : "secondary"}
onClick={() => setFilter("admin")}
>
Admins
</Button>
<Button
variant={filter === "superAdmin" ? "primary" : "secondary"}
onClick={() => setFilter("superAdmin")}
>
Super Admins
</Button>
</div>
<div className="space-y-4">
{/* Header Section */}
<div>
<h3 className="text-lg font-bold text-gray-900">User Management</h3>
<p className="text-sm text-gray-600 mt-1">Manage and monitor your fitness clients</p>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleRefresh}>
Refresh
</Button>
<Button variant="secondary" onClick={handleExport}>
Export CSV
</Button>
</div>
{/* Filter Buttons */}
<div className="flex flex-wrap gap-2">
<Button
variant={filter === "all" ? "primary" : "secondary"}
onClick={() => setFilter("all")}
className="text-xs"
>
All Users
</Button>
<Button
variant={filter === "client" ? "primary" : "secondary"}
onClick={() => setFilter("client")}
className="text-xs"
>
Clients
</Button>
<Button
variant={filter === "trainer" ? "primary" : "secondary"}
onClick={() => setFilter("trainer")}
className="text-xs"
>
Trainers
</Button>
<Button
variant={filter === "admin" ? "primary" : "secondary"}
onClick={() => setFilter("admin")}
className="text-xs"
>
Admins
</Button>
<Button
variant={filter === "superAdmin" ? "primary" : "secondary"}
onClick={() => setFilter("superAdmin")}
className="text-xs"
>
Super Admins
</Button>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
onClick={() => selectedUser && handleEditUser(selectedUser)}
disabled={!selectedUser}
className="text-xs"
>
Edit User
</Button>
<Button
variant="secondary"
onClick={() => {
setEditForm({
firstName: "",
lastName: "",
email: "",
role: "client",
phone: "",
});
setSelectedUser(null);
setIsEditing(true);
}}
className="text-xs"
>
Invite User
</Button>
<Button
variant="secondary"
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
disabled={!selectedUser}
className="text-xs"
>
Delete User
</Button>
<Button variant="secondary" onClick={handleRefresh} className="text-xs">
Refresh
</Button>
<Button variant="secondary" onClick={handleExport} className="text-xs">
Export CSV
</Button>
</div>
{/* Stats */}
<div className="text-sm text-gray-600 px-4 py-2 bg-gray-50 rounded-lg">
Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600 font-medium">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
)}
</div>
{/* User Grid */}
<Card>
<CardContent className="p-0">
<UserGrid
@@ -444,7 +463,7 @@ export function UserManagement() {
</a>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<h4 className="font-medium mb-2">Basic Information</h4>
<div className="space-y-1 text-sm">
@@ -500,6 +519,28 @@ export function UserManagement() {
</div>
</div>
)}
<div>
<h4 className="font-medium mb-2">Check-In Statistics</h4>
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">Last Check-In:</span>{" "}
{selectedUser.lastCheckInTime
? new Date(
selectedUser.lastCheckInTime,
).toLocaleString()
: "Never"}
</p>
<p>
<span className="font-medium">This Week:</span>{" "}
{selectedUser.checkInsThisWeek || 0} check-ins
</p>
<p>
<span className="font-medium">This Month:</span>{" "}
{selectedUser.checkInsThisMonth || 0} check-ins
</p>
</div>
</div>
</div>
</CardContent>
</Card>
+78 -33
View File
@@ -106,19 +106,61 @@ export class SQLiteDatabase implements IDatabase {
CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId);
`)
// Attendance table
this.db.exec(`
CREATE TABLE IF NOT EXISTS attendance (
id TEXT PRIMARY KEY,
clientId TEXT NOT NULL,
checkInTime DATETIME NOT NULL,
checkOutTime DATETIME,
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
notes TEXT,
createdAt DATETIME NOT NULL,
FOREIGN KEY (clientId) REFERENCES clients (id) ON DELETE CASCADE
)
`)
// Attendance table migration: change from clientId to userId
// Check if old table exists and migrate
const tableInfo = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'").get() as any;
if (tableInfo) {
// Check if table has clientId column (old schema)
const columns = this.db.prepare("PRAGMA table_info(attendance)").all() as any[];
const hasClientId = columns.some((col: any) => col.name === 'clientId');
if (hasClientId) {
console.log('Migrating attendance table from clientId to userId...');
// Create new table with userId
this.db.exec(`
CREATE TABLE IF NOT EXISTS attendance_new (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
checkInTime DATETIME NOT NULL,
checkOutTime DATETIME,
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
notes TEXT,
createdAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`);
// Migrate data: map clientId to userId via clients table
this.db.exec(`
INSERT INTO attendance_new (id, userId, checkInTime, checkOutTime, type, notes, createdAt)
SELECT a.id, c.userId, a.checkInTime, a.checkOutTime, a.type, a.notes, a.createdAt
FROM attendance a
JOIN clients c ON a.clientId = c.id
`);
// Drop old table and rename new one
this.db.exec(`DROP TABLE attendance`);
this.db.exec(`ALTER TABLE attendance_new RENAME TO attendance`);
console.log('Attendance table migration completed');
}
} else {
// Create new attendance table with userId
this.db.exec(`
CREATE TABLE IF NOT EXISTS attendance (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
checkInTime DATETIME NOT NULL,
checkOutTime DATETIME,
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
notes TEXT,
createdAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`);
}
// Recommendations table
// Removed DROP TABLE to persist data. Schema is now stable.
@@ -140,16 +182,16 @@ export class SQLiteDatabase implements IDatabase {
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
)
`)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
`)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
CREATE INDEX IF NOT EXISTS idx_attendance_userId ON attendance(userId);
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
`)
`);
}
// User operations
@@ -389,7 +431,7 @@ export class SQLiteDatabase implements IDatabase {
}
// Attendance operations
async checkIn(clientId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> {
async checkIn(userId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> {
if (!this.db) throw new Error('Database not connected')
const id = Math.random().toString(36).substr(2, 9)
@@ -397,7 +439,7 @@ export class SQLiteDatabase implements IDatabase {
const attendance: Attendance = {
id,
clientId,
userId,
checkInTime: now,
type,
notes,
@@ -405,20 +447,23 @@ export class SQLiteDatabase implements IDatabase {
}
const stmt = this.db.prepare(
`INSERT INTO attendance(id, clientId, checkInTime, type, notes, createdAt)
`INSERT INTO attendance(id, userId, checkInTime, type, notes, createdAt)
VALUES(?, ?, ?, ?, ?, ?)`
)
stmt.run(
attendance.id, attendance.clientId, attendance.checkInTime.toISOString(),
attendance.id, attendance.userId, attendance.checkInTime.toISOString(),
attendance.type, attendance.notes, attendance.createdAt.toISOString()
)
// Update client last visit
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
now.toISOString(),
clientId
)
// Update client last visit if user is a client
const client = await this.getClientByUserId(userId);
if (client) {
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
now.toISOString(),
client.id
);
}
return attendance
}
@@ -440,10 +485,10 @@ export class SQLiteDatabase implements IDatabase {
return row ? this.mapRowToAttendance(row) : null
}
async getAttendanceHistory(clientId: string): Promise<Attendance[]> {
async getAttendanceHistory(userId: string): Promise<Attendance[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? ORDER BY checkInTime DESC')
const rows = stmt.all(clientId)
const stmt = this.db.prepare('SELECT * FROM attendance WHERE userId = ? ORDER BY checkInTime DESC')
const rows = stmt.all(userId)
return rows.map(row => this.mapRowToAttendance(row))
}
@@ -454,10 +499,10 @@ export class SQLiteDatabase implements IDatabase {
return rows.map(row => this.mapRowToAttendance(row))
}
async getActiveCheckIn(clientId: string): Promise<Attendance | null> {
async getActiveCheckIn(userId: string): Promise<Attendance | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
const row = stmt.get(clientId)
const stmt = this.db.prepare('SELECT * FROM attendance WHERE userId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
const row = stmt.get(userId)
return row ? this.mapRowToAttendance(row) : null
}
@@ -509,7 +554,7 @@ export class SQLiteDatabase implements IDatabase {
private mapRowToAttendance(row: any): Attendance {
return {
id: row.id,
clientId: row.clientId,
userId: row.userId,
checkInTime: new Date(row.checkInTime),
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
type: row.type,
+4 -4
View File
@@ -39,7 +39,7 @@ export interface FitnessProfile {
export interface Attendance {
id: string;
clientId: string;
userId: string;
checkInTime: Date;
checkOutTime?: Date;
type: "gym" | "class" | "personal_training";
@@ -121,14 +121,14 @@ export interface IDatabase {
// Attendance operations
checkIn(
clientId: string,
userId: string,
type: "gym" | "class" | "personal_training",
notes?: string,
): Promise<Attendance>;
checkOut(attendanceId: string): Promise<Attendance | null>;
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
getAttendanceHistory(userId: string): Promise<Attendance[]>;
getAllAttendance(): Promise<Attendance[]>;
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
getActiveCheckIn(userId: string): Promise<Attendance | null>;
// Recommendation operations
createRecommendation(
+1
View File
@@ -22,6 +22,7 @@
"name": "next"
}
],
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": [
+11 -4
View File
@@ -15,21 +15,28 @@
"**/*"
],
"ios": {
"supportsTablet": true
"supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"permissions": [
"CAMERA"
]
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-font"
"expo-font",
"expo-barcode-scanner"
],
"scheme": "fitai"
}
}
}
+33 -1
View File
@@ -20,13 +20,15 @@
"axios": "^1.6.0",
"expo": "~54.0.23",
"expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0",
"expo-barcode-scanner": "^13.0.1",
"expo-camera": "~17.0.9",
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-font": "~14.0.9",
"expo-haptics": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.0",
"expo-location": "~17.0.1",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
@@ -7203,6 +7205,18 @@
"react-native": "*"
}
},
"node_modules/expo-barcode-scanner": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/expo-barcode-scanner/-/expo-barcode-scanner-13.0.1.tgz",
"integrity": "sha512-xBGLT1An2gpAMIQRTLU3oHydKohX8r8F9/ait1Fk9Vgd0GraFZbP4IiT7nHMlaw4H6E7Muucf7vXpGV6u7d4HQ==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~4.7.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-camera": {
"version": "17.0.9",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz",
@@ -7282,6 +7296,15 @@
"expo": "*"
}
},
"node_modules/expo-image-loader": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.7.0.tgz",
"integrity": "sha512-cx+MxxsAMGl9AiWnQUzrkJMJH4eNOGlu7XkLGnAXSJrRoIiciGaKqzeaD326IyCTV+Z1fXvIliSgNW+DscvD8g==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-linear-gradient": {
"version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz",
@@ -7307,6 +7330,15 @@
"react-native": "*"
}
},
"node_modules/expo-location": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/expo-location/-/expo-location-17.0.1.tgz",
"integrity": "sha512-m+OzotzlAXO3ZZ1uqW5GC25nXW868zN+ROyBA1V4VF6jGay1ZEs4URPglCVUDzZby2F5wt24cMzqDKw2IX6nRw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.22",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz",
+3 -1
View File
@@ -26,13 +26,15 @@
"axios": "^1.6.0",
"expo": "~54.0.23",
"expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0",
"expo-barcode-scanner": "^13.0.1",
"expo-camera": "~17.0.9",
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-font": "~14.0.9",
"expo-haptics": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.0",
"expo-location": "~17.0.1",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2FB7E8;stop-opacity:1" />
<stop offset="50%" style="stop-color:#0B9FD0;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0B7FB3;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main arrow shape -->
<path d="M 420 150 L 540 150 L 420 300 Z" fill="url(#grad1)" opacity="0.95"/>
<path d="M 180 150 L 300 300 L 180 300 Z" fill="url(#grad1)" opacity="0.85"/>
<!-- Dumbbell - Left -->
<g transform="translate(200, 250)">
<!-- Left disk -->
<circle cx="0" cy="0" r="35" fill="white" opacity="0.95"/>
<!-- Bar left -->
<rect x="35" y="-15" width="80" height="30" fill="white" rx="4"/>
<!-- Right disk -->
<circle cx="115" cy="0" r="35" fill="white" opacity="0.95"/>
<!-- Grip lines -->
<line x1="45" y1="-35" x2="45" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="65" y1="-35" x2="65" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="85" y1="-35" x2="85" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
<line x1="105" y1="-35" x2="105" y2="35" stroke="url(#grad1)" stroke-width="3" opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -6,6 +6,7 @@ import { Ionicons } from '@expo/vector-icons'
import { attendanceApi, Attendance } from '../../api/attendance'
import { theme } from '../../styles/theme'
import { Animated } from 'react-native'
import { GeofenceStatus } from '../../components/GeofenceStatus'
export default function AttendanceScreen() {
const { getToken, userId } = useAuth()
@@ -111,6 +112,9 @@ export default function AttendanceScreen() {
<Text style={styles.subtitle}>Track your gym visits</Text>
</LinearGradient>
{/* Geofencing Status Card */}
<GeofenceStatus />
<View style={styles.actionContainer}>
{activeCheckIn ? (
<LinearGradient
+27 -3
View File
@@ -9,6 +9,8 @@ import { QuickActionGrid } from "../../components/QuickActionGrid";
import { TrackMealModal } from "../../components/TrackMealModal";
import { AddWaterModal } from "../../components/AddWaterModal";
import { HydrationWidget } from "../../components/HydrationWidget";
import { ScanFoodModal } from "../../components/ScanFoodModal";
import { PerformanceMetrics } from "../../components/PerformanceMetrics";
import { Ionicons } from "@expo/vector-icons";
export default function HomeScreen() {
@@ -16,6 +18,7 @@ export default function HomeScreen() {
const [refreshing, setRefreshing] = useState(false);
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
const [scanFoodModalVisible, setScanFoodModalVisible] = useState(false);
const [calories, setCalories] = useState(0);
const [waterIntake, setWaterIntake] = useState(0);
@@ -51,6 +54,11 @@ export default function HomeScreen() {
setWaterIntake(0);
};
const handleAddScannedFood = (scannedCalories: number) => {
setCalories(prev => prev + scannedCalories);
setScanFoodModalVisible(false);
};
const resetAllCounters = async () => {
setCalories(0);
setWaterIntake(0);
@@ -102,9 +110,15 @@ export default function HomeScreen() {
>
{/* Header Section */}
<View style={styles.header}>
<View>
<Text style={styles.greeting}>{getGreeting()},</Text>
<Text style={styles.name}>{user?.firstName || "Athlete"}</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Image
source={require("../../../public/nextform-logo.png")}
style={{ width: 40, height: 40, resizeMode: 'contain' }}
/>
<View>
<Text style={styles.greeting}>{getGreeting()},</Text>
<Text style={styles.name}>{user?.firstName || "Athlete"}</Text>
</View>
</View>
<View style={styles.avatarContainer}>
{user?.imageUrl ? (
@@ -124,6 +138,9 @@ export default function HomeScreen() {
duration={45}
/>
{/* Performance Metrics */}
<PerformanceMetrics />
{/* Hydration Widget */}
<HydrationWidget
current={waterIntake}
@@ -134,6 +151,7 @@ export default function HomeScreen() {
<QuickActionGrid
onTrackMealPress={() => setTrackMealModalVisible(true)}
onAddWaterPress={() => setAddWaterModalVisible(true)}
onScanFoodPress={() => setScanFoodModalVisible(true)}
/>
<TrackMealModal
@@ -150,6 +168,12 @@ export default function HomeScreen() {
onResetData={handleResetWater}
/>
<ScanFoodModal
visible={scanFoodModalVisible}
onClose={() => setScanFoodModalVisible(false)}
onAddFood={handleAddScannedFood}
/>
{/* Recent Activity Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
@@ -16,7 +16,7 @@ export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProp
return (
<View style={styles.container}>
<LinearGradient
colors={theme.gradients.dark}
colors={['#0c4a6e', '#0369a1', '#0ea5e9']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, theme.shadows.medium]}
@@ -0,0 +1,119 @@
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { Ionicons } from '@expo/vector-icons'
import { useState } from 'react'
export const GeofenceStatus = () => {
const [isInside, setIsInside] = useState(false)
const [distance, setDistance] = useState('250 m')
const [loading, setLoading] = useState(false)
const handleRefresh = () => {
setLoading(true)
// Simulate location check
setTimeout(() => {
setDistance(Math.random() > 0.5 ? '150 m' : '300 m')
setIsInside(Math.random() > 0.5)
setLoading(false)
}, 1000)
}
return (
<View style={styles.container}>
<LinearGradient
colors={
isInside
? ['#10b981', '#6ee7b7', '#a7f3d0']
: ['#64748b', '#94a3b8', '#cbd5e1']
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.card}
>
<View style={styles.header}>
<View style={styles.titleRow}>
<Ionicons
name={isInside ? 'location-sharp' : 'location'}
size={24}
color="#fff"
/>
<Text style={styles.title}>
{isInside ? 'At Gym' : 'Away from Gym'}
</Text>
</View>
<TouchableOpacity
onPress={handleRefresh}
disabled={loading}
style={styles.refreshButton}
>
<Ionicons
name="refresh"
size={20}
color="#fff"
style={loading ? { opacity: 0.5 } : {}}
/>
</TouchableOpacity>
</View>
<View style={styles.content}>
<View style={styles.stat}>
<Text style={styles.statLabel}>Distance</Text>
<Text style={styles.statValue}>{distance}</Text>
</View>
</View>
</LinearGradient>
</View>
)
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
paddingVertical: 12,
},
card: {
borderRadius: 16,
padding: 16,
overflow: 'hidden',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#fff',
},
refreshButton: {
padding: 8,
borderRadius: 8,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
content: {
marginTop: 12,
},
stat: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
statLabel: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
fontWeight: '500',
},
statValue: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
})
@@ -15,7 +15,7 @@ export function HydrationWidget({ current, goal }: HydrationWidgetProps) {
return (
<View style={styles.container}>
<LinearGradient
colors={['#e0f2fe', '#dbeafe']} // Light blue background
colors={['#0369a1', '#0ea5e9', '#06b6d4']}
style={[styles.card, theme.shadows.subtle]}
>
<View style={styles.content}>
@@ -0,0 +1,224 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { theme } from '../styles/theme';
interface MetricProps {
title: string;
value: string | number;
change: string;
trend: 'up' | 'down';
icon: string;
colorScheme: 'blue' | 'green' | 'purple' | 'orange';
}
const getColorScheme = (colorScheme: 'blue' | 'green' | 'purple' | 'orange') => {
const schemes = {
blue: {
colors: ['#1e40af', '#0369a1', '#06b6d4'] as const,
text: '#ffffff',
label: '#e0f2fe',
badge: '#0ea5e9',
badgeText: '#ffffff',
icon: '#06b6d4',
},
green: {
colors: ['#be185d', '#ec4899', '#f472b6'] as const,
text: '#ffffff',
label: '#fbcfe8',
badge: '#ec4899',
badgeText: '#ffffff',
icon: '#f472b6',
},
purple: {
colors: ['#b45309', '#d97706', '#fbbf24'] as const,
text: '#ffffff',
label: '#fef3c7',
badge: '#f59e0b',
badgeText: '#ffffff',
icon: '#fbbf24',
},
orange: {
colors: ['#047857', '#059669', '#10b981'] as const,
text: '#ffffff',
label: '#d1fae5',
badge: '#10b981',
badgeText: '#ffffff',
icon: '#34d399',
},
};
return schemes[colorScheme];
};
const MetricCard: React.FC<MetricProps> = ({ title, value, change, trend, icon, colorScheme }) => {
const colors = getColorScheme(colorScheme);
const isPositive = trend === 'up';
const trendIcon = isPositive ? 'arrow-up' : 'arrow-down';
return (
<LinearGradient
colors={colors.colors as any}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.card}
>
<View style={styles.cardContent}>
<View style={styles.header}>
<Text style={[styles.label, { color: colors.label }]}>{title}</Text>
<View style={[styles.iconContainer, { backgroundColor: colors.icon + '40' }]}>
<Ionicons name={icon as any} size={20} color={colors.text} />
</View>
</View>
<Text style={[styles.value, { color: colors.text }]}>{value}</Text>
<View style={styles.changeContainer}>
<View style={[styles.trendBadge, { backgroundColor: colors.badge }]}>
<Ionicons
name={trendIcon}
size={12}
color={colors.badgeText}
style={{ marginRight: 4 }}
/>
<Text style={[styles.changeText, { color: colors.badgeText, fontWeight: '700' }]}>
{trend === 'up' ? '↑' : '↓'} {change}
</Text>
</View>
<Text style={[styles.compareText, { color: colors.text }]}>vs month</Text>
</View>
</View>
</LinearGradient>
);
};
export const PerformanceMetrics: React.FC = () => {
const metrics: MetricProps[] = [
{
title: 'Total Users',
value: '0',
change: '+12%',
trend: 'up',
icon: 'people',
colorScheme: 'blue',
},
{
title: 'Active Clients',
value: '0',
change: '+5%',
trend: 'up',
icon: 'person-add',
colorScheme: 'green',
},
{
title: 'Revenue',
value: '$0.00',
change: '0%',
trend: 'down',
icon: 'wallet',
colorScheme: 'purple',
},
{
title: 'Growth',
value: '24%',
change: '-2%',
trend: 'down',
icon: 'trending-up',
colorScheme: 'orange',
},
];
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Performance metrics & athlete insights</Text>
</View>
<View style={styles.metricsGrid}>
{metrics.map((metric, index) => (
<View key={index} style={styles.metricWrapper}>
<MetricCard {...metric} />
</View>
))}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
paddingHorizontal: 20,
marginBottom: 24,
},
header: {
marginBottom: 16,
},
title: {
fontSize: 14,
fontWeight: '600',
color: '#4b5563',
letterSpacing: 0.3,
},
metricsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
gap: 12,
},
metricWrapper: {
width: '48%',
},
card: {
borderRadius: 20,
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
},
cardContent: {
padding: 16,
minHeight: 160,
justifyContent: 'space-between',
},
iconContainer: {
width: 36,
height: 36,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
label: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.6,
marginBottom: 12,
textTransform: 'uppercase',
},
value: {
fontSize: 28,
fontWeight: '800',
marginBottom: 12,
},
changeContainer: {
alignItems: 'flex-start',
},
trendBadge: {
flexDirection: 'row',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
alignItems: 'center',
marginBottom: 6,
},
changeText: {
fontSize: 11,
fontWeight: '700',
},
compareText: {
fontSize: 10,
fontWeight: '500',
},
});
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import QRCode from 'react-native-qrcode-svg';
interface QRCodeProps {
value: string;
size?: number;
logo?: string;
}
export const QRCodeGenerator: React.FC<QRCodeProps> = ({
value,
size = 250
}) => {
return (
<View style={styles.container}>
<View style={styles.qrWrapper}>
<QRCode
value={value}
size={size}
color="black"
backgroundColor="white"
quietZone={10}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
qrWrapper: {
padding: 16,
backgroundColor: 'white',
borderRadius: 12,
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
});
+92
View File
@@ -0,0 +1,92 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Button, Alert } from 'react-native';
import { BarCodeScanner } from 'expo-barcode-scanner';
interface QRScannerProps {
onScan: (data: string) => void;
onClose?: () => void;
}
export const QRScanner: React.FC<QRScannerProps> = ({ onScan, onClose }) => {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [scanned, setScanned] = useState(false);
useEffect(() => {
const getBarCodeScannerPermissions = async () => {
const { status } = await BarCodeScanner.requestPermissionsAsync();
setHasPermission(status === 'granted');
};
getBarCodeScannerPermissions();
}, []);
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
setScanned(true);
onScan(data);
Alert.alert('QR Code', `Scanned: ${data}`, [
{ text: 'OK', onPress: () => setScanned(false) },
]);
};
if (hasPermission === null) {
return (
<View style={styles.container}>
<Text>Requesting for camera permission...</Text>
</View>
);
}
if (hasPermission === false) {
return (
<View style={styles.container}>
<Text>No access to camera</Text>
{onClose && <Button title="Close" onPress={onClose} />}
</View>
);
}
return (
<View style={styles.container}>
<BarCodeScanner
onBarCodeScanned={scanned ? undefined : handleBarCodeScanned}
style={StyleSheet.absoluteFillObject}
/>
<View style={styles.overlay}>
<View style={styles.scanBox} />
</View>
{onClose && (
<View style={styles.buttonContainer}>
<Button title="Close Scanner" onPress={onClose} color="#FF6B6B" />
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
},
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
scanBox: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: '#4CAF50',
borderRadius: 10,
backgroundColor: 'transparent',
},
buttonContainer: {
position: 'absolute',
bottom: 20,
left: 20,
right: 20,
},
});
@@ -39,9 +39,10 @@ function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
interface QuickActionGridProps {
onTrackMealPress?: () => void;
onAddWaterPress?: () => void;
onScanFoodPress?: () => void;
}
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActionGridProps) {
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) {
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
@@ -67,6 +68,7 @@ export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActi
icon="scan"
label="Scan Food"
gradient={theme.gradients.purple}
onPress={onScanFoodPress}
/>
</View>
</View>
@@ -0,0 +1,485 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, Alert, Platform } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
interface ScanFoodModalProps {
visible: boolean;
onClose: () => void;
onAddFood: (calories: number) => void;
}
// Mock food database
const FOOD_DATABASE: { [key: string]: { name: string; calories: number; servingSize: string } } = {
'0123456789': { name: 'Apple', calories: 95, servingSize: '1 medium' },
'9876543210': { name: 'Banana', calories: 105, servingSize: '1 medium' },
'5555555555': { name: 'Protein Bar', calories: 200, servingSize: '1 bar' },
'1111111111': { name: 'Greek Yogurt', calories: 150, servingSize: '1 cup' },
};
export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProps) {
const [permission, requestPermission] = Platform.OS === 'web' ? [null, null] : useCameraPermissions() as any;
const [scanned, setScanned] = useState(false);
const [foodData, setFoodData] = useState<{ name: string; calories: number; servingSize: string } | null>(null);
const [servings, setServings] = useState('1');
useEffect(() => {
if (visible) {
setScanned(false);
setFoodData(null);
setServings('1');
}
}, [visible]);
const handleBarCodeScanned = ({ data }: { data: string }) => {
if (scanned) return;
setScanned(true);
// Look up food in database
const food = FOOD_DATABASE[data];
if (food) {
setFoodData(food);
} else {
// Mock data for unknown barcodes
setFoodData({
name: 'Unknown Food',
calories: 150,
servingSize: '1 serving'
});
}
};
const handleConfirm = () => {
if (foodData) {
const totalCalories = foodData.calories * parseFloat(servings || '1');
onAddFood(totalCalories);
onClose();
}
};
const handleRescan = () => {
setScanned(false);
setFoodData(null);
setServings('1');
};
if (!permission) {
if (Platform.OS === 'web') {
// On web, show normal modal without permissions
return (
<Modal visible={visible} animationType="slide">
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color="#fff" />
</TouchableOpacity>
<Text style={styles.title}>Scan Food Barcode</Text>
<View style={{ width: 28 }} />
</View>
<View style={styles.webPlaceholder}>
<Text style={styles.webText}>Barcode scanning is only available on mobile devices</Text>
</View>
</View>
</Modal>
);
}
return null;
}
if (!permission.granted && Platform.OS !== 'web') {
return (
<Modal visible={visible} transparent animationType="slide">
<View style={styles.permissionContainer}>
<View style={styles.permissionContent}>
<Ionicons name="camera-outline" size={64} color={theme.colors.primary} />
<Text style={styles.permissionTitle}>Camera Permission Required</Text>
<Text style={styles.permissionText}>
We need access to your camera to scan food barcodes.
</Text>
<TouchableOpacity onPress={requestPermission} style={styles.permissionButtonContainer}>
<LinearGradient
colors={theme.gradients.primary}
style={styles.permissionButton}
>
<Text style={styles.permissionButtonText}>Grant Permission</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity onPress={onClose} style={styles.cancelButton}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
return (
<Modal visible={visible} animationType="slide">
<View style={styles.container}>
{!foodData ? (
<>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color="#fff" />
</TouchableOpacity>
<Text style={styles.title}>Scan Food Barcode</Text>
<View style={{ width: 28 }} />
</View>
{Platform.OS === 'web' ? (
<View style={styles.webPlaceholder}>
<Text style={styles.webText}>Barcode scanning is only available on mobile devices</Text>
</View>
) : (
<CameraView
style={styles.camera}
facing="back"
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'code39'],
}}
>
<View style={styles.scanOverlay}>
<View style={styles.scanFrame} />
<Text style={styles.scanText}>Position barcode within frame</Text>
</View>
</CameraView>
)}
</>
) : (
<View style={styles.resultContainer}>
<View style={styles.resultHeader}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color={theme.colors.gray900} />
</TouchableOpacity>
<Text style={styles.resultTitle}>Food Details</Text>
<View style={{ width: 28 }} />
</View>
<View style={styles.foodCard}>
<View style={styles.foodIconContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.foodIcon}
>
<Ionicons name="restaurant" size={32} color="#fff" />
</LinearGradient>
</View>
<Text style={styles.foodName}>{foodData.name}</Text>
<Text style={styles.servingSize}>{foodData.servingSize}</Text>
<View style={styles.caloriesBadge}>
<Text style={styles.caloriesValue}>{foodData.calories}</Text>
<Text style={styles.caloriesLabel}>kcal per serving</Text>
</View>
<View style={styles.servingsContainer}>
<Text style={styles.label}>Number of Servings</Text>
<View style={styles.servingsInput}>
<TouchableOpacity
onPress={() => setServings(String(Math.max(0.5, parseFloat(servings || '1') - 0.5)))}
style={styles.servingsButton}
>
<Ionicons name="remove" size={20} color={theme.colors.primary} />
</TouchableOpacity>
<TextInput
style={styles.servingsValue}
value={servings}
onChangeText={setServings}
keyboardType="decimal-pad"
/>
<TouchableOpacity
onPress={() => setServings(String(parseFloat(servings || '1') + 0.5))}
style={styles.servingsButton}
>
<Ionicons name="add" size={20} color={theme.colors.primary} />
</TouchableOpacity>
</View>
</View>
<View style={styles.totalCalories}>
<Text style={styles.totalLabel}>Total Calories</Text>
<Text style={styles.totalValue}>
{Math.round(foodData.calories * parseFloat(servings || '1'))} kcal
</Text>
</View>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity onPress={handleRescan} style={styles.rescanButton}>
<Text style={styles.rescanButtonText}>Scan Again</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleConfirm} style={styles.confirmButtonContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.confirmButton}
>
<Text style={styles.confirmButtonText}>Add to Diary</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
)}
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#fff',
},
camera: {
flex: 1,
},
webPlaceholder: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#1a1a1a',
},
webText: {
color: '#999',
fontSize: 16,
textAlign: 'center',
paddingHorizontal: 40,
},
scanOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
scanFrame: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: '#fff',
borderRadius: 20,
backgroundColor: 'transparent',
},
scanText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
marginTop: 24,
textAlign: 'center',
},
resultContainer: {
flex: 1,
backgroundColor: theme.colors.background,
},
resultHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
backgroundColor: '#fff',
},
resultTitle: {
fontSize: 18,
fontWeight: '700',
color: theme.colors.gray900,
},
foodCard: {
margin: 20,
padding: 24,
backgroundColor: '#fff',
borderRadius: 24,
alignItems: 'center',
...theme.shadows.medium,
},
foodIconContainer: {
marginBottom: 16,
},
foodIcon: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
},
foodName: {
fontSize: 24,
fontWeight: '700',
color: theme.colors.gray900,
marginBottom: 8,
textAlign: 'center',
},
servingSize: {
fontSize: 16,
color: theme.colors.gray600,
marginBottom: 24,
},
caloriesBadge: {
backgroundColor: theme.colors.gray50,
paddingHorizontal: 24,
paddingVertical: 16,
borderRadius: 16,
alignItems: 'center',
marginBottom: 32,
},
caloriesValue: {
fontSize: 32,
fontWeight: '700',
color: theme.colors.primary,
},
caloriesLabel: {
fontSize: 14,
color: theme.colors.gray600,
marginTop: 4,
},
servingsContainer: {
width: '100%',
marginBottom: 24,
},
label: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray700,
marginBottom: 12,
},
servingsInput: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
},
servingsButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: theme.colors.gray100,
justifyContent: 'center',
alignItems: 'center',
},
servingsValue: {
fontSize: 24,
fontWeight: '700',
color: theme.colors.gray900,
textAlign: 'center',
minWidth: 60,
},
totalCalories: {
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: theme.colors.gray200,
},
totalLabel: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray700,
},
totalValue: {
fontSize: 20,
fontWeight: '700',
color: theme.colors.gray900,
},
buttonRow: {
flexDirection: 'row',
padding: 20,
gap: 12,
},
rescanButton: {
flex: 1,
paddingVertical: 16,
borderRadius: 20,
backgroundColor: theme.colors.gray100,
alignItems: 'center',
},
rescanButtonText: {
fontSize: 16,
fontWeight: '700',
color: theme.colors.gray700,
},
confirmButtonContainer: {
flex: 1,
},
confirmButton: {
paddingVertical: 16,
borderRadius: 20,
alignItems: 'center',
},
confirmButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
permissionContent: {
backgroundColor: '#fff',
borderRadius: 24,
padding: 32,
margin: 20,
alignItems: 'center',
...theme.shadows.strong,
},
permissionTitle: {
fontSize: 20,
fontWeight: '700',
color: theme.colors.gray900,
marginTop: 16,
marginBottom: 8,
},
permissionText: {
fontSize: 16,
color: theme.colors.gray600,
textAlign: 'center',
marginBottom: 24,
},
permissionButtonContainer: {
width: '100%',
marginBottom: 12,
},
permissionButton: {
paddingVertical: 16,
borderRadius: 20,
alignItems: 'center',
},
permissionButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
cancelButton: {
paddingVertical: 12,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray600,
},
});
+1 -1
View File
@@ -1,5 +1,5 @@
export const API_BASE_URL = __DEV__
? 'https://694d46f62d87.ngrok-free.app'
? 'https://8679109544e4.ngrok-free.app'
: 'https://your-production-url.com'
export const API_ENDPOINTS = {
+8 -4
View File
@@ -1,13 +1,17 @@
import React from "react";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
export const useWarmUpBrowser = () => {
React.useEffect(() => {
// Warm up the android browser to improve UX
// https://docs.expo.dev/guides/authentication/#improving-user-experience
void WebBrowser.warmUpAsync();
return () => {
void WebBrowser.coolDownAsync();
};
// Only available on native platforms (iOS/Android), not on web
if (Platform.OS !== "web") {
void WebBrowser.warmUpAsync();
return () => {
void WebBrowser.coolDownAsync();
};
}
}, []);
};
+74
View File
@@ -0,0 +1,74 @@
/**
* Geofencing Service
* Handles gym location validation and distance calculations
*/
// Gym location coordinates - Teretana Isaija Mazhovski, Skopje, North Macedonia
export const GYM_LOCATION = {
latitude: 41.9973,
longitude: 21.4280,
};
// Geofence radius in meters (500m default)
export const GEOFENCE_RADIUS_METERS = 500;
/**
* Calculate distance between two coordinates using Haversine formula
* @param lat1 Starting latitude
* @param lon1 Starting longitude
* @param lat2 Ending latitude
* @param lon2 Ending longitude
* @returns Distance in meters
*/
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371000; // Earth's radius in meters
const φ1 = (lat1 * Math.PI) / 180;
const φ2 = (lat2 * Math.PI) / 180;
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
}
/**
* Check if a location is within the geofence
* @param userLat User's latitude
* @param userLon User's longitude
* @param radius Geofence radius in meters (defaults to GEOFENCE_RADIUS_METERS)
* @returns Boolean indicating if user is within geofence
*/
export function isWithinGeofence(
userLat: number,
userLon: number,
radius: number = GEOFENCE_RADIUS_METERS
): boolean {
const distance = calculateDistance(
userLat,
userLon,
GYM_LOCATION.latitude,
GYM_LOCATION.longitude
);
return distance <= radius;
}
/**
* Get formatted distance string
* @param meters Distance in meters
* @returns Formatted distance string
*/
export function getFormattedDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(2)} km`;
}
+5
View File
@@ -64,6 +64,11 @@ export const theme = {
forest: ['#10b981', '#059669'] as const,
lavender: ['#a78bfa', '#ec4899'] as const,
dark: ['#1e293b', '#0f172a'] as const,
// Premium metallic gradients
blueMetallic: ['#1e40af', '#06b6d4', '#10b981'] as const,
amberMetallic: ['#b45309', '#f59e0b', '#fbbf24'] as const,
magentaMetallic: ['#be185d', '#ec4899', '#f472b6'] as const,
emeraldMetallic: ['#047857', '#10b981', '#6ee7b7'] as const,
},
// Shadow System
+1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"ignoreDeprecations": "6.0",
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
+171 -12
View File
@@ -1,15 +1,174 @@
## fitai
# FitAI
# description
Integrated AI solution for fitness houses and their clients with Clerk authentication.
- fitai is integrated ai solution for fitness houses and their clients,
its allow to easy menagment of clients, tracking of payments, usage of resourcess,
attendance, habits etc.
these will be phase one:
solution is composed of a admin app, where we are doing managment tasks, we visualize and
expose importatnt data to menagment and trainers, and a expo/reactnative mobile app for users.
via app we will be tracking attendance and payments, we will be sending notification etc.
## Project Structure
# phase 2
we will be tracking user inputs via manual input and devices, backend will analyze data and propose
excercises etc.
```
fitai/
├── apps/
│ ├── admin/ # Next.js admin dashboard
│ └── mobile/ # React Native mobile app (Expo)
├── packages/
│ └── shared/ # Shared types and utilities
└── AGENTS.md # Development guidelines
```
## Getting Started
### Prerequisites
- Node.js >= 18.0.0
- npm >= 9.0.0
- Clerk account (sign up at https://clerk.com)
### Installation
```bash
# Install root dependencies
npm install
# Install admin dependencies
cd apps/admin && npm install
# Install mobile dependencies
cd apps/mobile && npm install --legacy-peer-deps
```
### Authentication Setup
FitAI uses Clerk for authentication. Follow these steps:
1. **Create a Clerk account** at https://dashboard.clerk.com
2. **Create a new application** in the Clerk dashboard
3. **Copy your API keys** (Publishable Key and Secret Key)
4. **Configure environment variables**:
**Admin App** (`apps/admin/.env.local`):
```env
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_key_here
CLERK_SECRET_KEY=sk_test_your_key_here
```
**Mobile App** (`apps/mobile/.env`):
```env
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_key_here
```
📖 **See [CLERK_SETUP.md](./CLERK_SETUP.md) for detailed setup instructions**
### Development
**Important**: Set up environment variables before running the apps!
```bash
# Admin dashboard (http://localhost:3000)
cd apps/admin && npm run dev
# Mobile app (http://localhost:8081) - Requires Expo SDK 54
cd apps/mobile && npm start
```
**First-time setup checklist**:
- [ ] Create Clerk account and application
- [ ] Add API keys to `.env.local` (admin) and `.env` (mobile)
- [ ] Verify both apps start without errors
- [ ] Test sign-up and sign-in flows
### Mobile App Setup
- **Expo SDK**: 50 (stable, compatible with Expo Go)
- **Assets**: Placeholder icons and splash screen included
- **Navigation**: Expo Router with tab-based layout
- **Authentication**: Secure storage with expo-secure-store
- **Babel**: babel-preset-expo for proper transpilation
### Known Compatibility Notes
- Use Expo Go with SDK 50 for mobile testing
- For SDK 54, upgrade all dependencies to latest versions
- Current setup prioritizes stability over latest features
### Build & Test
```bash
# Build all apps
npm run build
# Run tests
npm test
# Lint code
npm run lint
# Type checking
npm run typecheck
```
## Features
### Authentication (Clerk)
- 🔐 Secure email/password authentication
- ✉️ Email verification
- 🔄 Session management
- 🎨 Customizable UI components
- 📱 Multi-platform support (Web + Mobile)
- 🛡️ Built-in security features
### Admin Dashboard
- 👥 User management (CRUD operations)
- 📊 Analytics dashboard with charts
- 🎯 Role-based access control
- 📈 Data visualization with AG Grid
- 💳 Payment tracking (coming soon)
- 📅 Attendance monitoring (coming soon)
### Mobile App
- 🔐 Secure sign-in/sign-up
- 👤 User profile management
- 📱 Native mobile experience
- 🔔 Push notifications ready
- ✅ Attendance check-in (coming soon)
- 💰 Payment history (coming soon)
## Tech Stack
### Authentication
- **Clerk**: Complete authentication and user management platform
### Frontend
- **Admin**: Next.js 14 (App Router), React 19, TypeScript, Tailwind CSS
- **Mobile**: React Native, Expo SDK 54, Expo Router, TypeScript
### Backend & Database
- **Database**: SQLite with Drizzle ORM
- **API**: Next.js API Routes (REST)
### Development Tools
- **State Management**: React Query, React Hook Form
- **Validation**: Zod schemas
- **Data Grid**: AG Grid for advanced user management
- **Charts**: AG Charts for analytics and visualization
- **Testing**: Jest, Testing Library (configured)
## Project Structure
```
fitai/
├── apps/
│ ├── admin/ # Next.js admin dashboard
│ │ ├── src/
│ │ │ ├── app/ # App Router pages & API routes
│ │ │ ├── components/
│ │ │ └── lib/ # Database & utilities
│ │ └── .env.local # Admin environment variables
│ │
│ └── mobile/ # Expo React Native app
│ ├── src/
│ │ ├── app/ # Expo Router screens
│ │ │ ├── (auth)/ # Authentication screens
│ │ │ └── (tabs)/ # Main app tabs
│ │ └── components/
│ └── .env # Mobile environment variables
│
├── packages/
│ ├── database/ # Drizzle ORM schemas & DB client
│ └── shared/ # Shared types & utilities
│
└── CLERK_SETUP.md # Detailed authentication setup guide
```
+19
View File
@@ -0,0 +1,19 @@
# Script to update NextForm logo from external folder
$sourceFolder = "c:\Users\PC\Desktop\NextForm – Your Smart Fitness Twin\sliki"
$adminPublic = "c:\Users\PC\Desktop\fitaiProto\apps\admin\public\nextform-logo.png"
$mobilePublic = "c:\Users\PC\Desktop\fitaiProto\apps\mobile\public\nextform-logo.png"
# Find the latest PNG in source folder
$latestPng = Get-ChildItem "$sourceFolder\*.png" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($latestPng) {
Copy-Item $latestPng.FullName $adminPublic -Force
Copy-Item $latestPng.FullName $mobilePublic -Force
Write-Host "✅ Logo updated from: $($latestPng.Name)"
Write-Host "Admin: $adminPublic"
Write-Host "Mobile: $mobilePublic"
} else {
Write-Host "❌ No PNG found in: $sourceFolder"
}