Compare commits
47
Commits
quickAction
..
ace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb7211f230 | ||
|
|
94beb7da78 | ||
|
|
1f7b90b816 | ||
|
|
04856021b9 | ||
|
|
c238e18766 | ||
|
|
0c272502f8 | ||
|
|
231ad44cc4 | ||
|
|
2f79d1547f | ||
|
|
337351dd06 | ||
|
|
fe3f93fb9f | ||
|
|
3c810003c5 | ||
|
|
6207949e2d | ||
|
|
76b9ee24c5 | ||
|
|
ad6fdc2226 | ||
|
|
8c2a3daee0 | ||
|
|
19ece90c6f | ||
|
|
b9efedbb88 | ||
|
|
204e6edca5 | ||
|
|
918357c00c | ||
|
|
4fbe8399df | ||
|
|
61bad8d30c | ||
|
|
966bcb084d | ||
|
|
71a05c51bf | ||
|
|
e4eadc858c | ||
|
|
0024510fdb | ||
|
|
c9fbab3bbb | ||
|
|
35d64ef3c0 | ||
|
|
6dc90f1745 | ||
|
|
02452c11e6 | ||
|
|
dd78602dd6 | ||
|
|
0a55438bce | ||
|
|
51df7f57ec | ||
|
|
feacef2a81 | ||
|
|
77546a5017 | ||
|
|
d38f0a4cc2 | ||
|
|
b6764b5ed6 | ||
|
|
e84d0f5f8f | ||
|
|
fda66a7703 | ||
|
|
2d9fbd186b | ||
|
|
bbd0cfde9c | ||
|
|
16e22f330a | ||
|
|
efa2f2a8d8 | ||
|
|
62e2255b5e | ||
|
|
d7d270510f | ||
|
|
a5c297b911 | ||
|
|
0011c9d4e5 | ||
|
|
db0d2cf215 |
No files matched your search
Binary file not shown.
@@ -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 |
@@ -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 |
@@ -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)
|
// Ensure user exists in DB (sync from Clerk if needed)
|
||||||
await ensureUserSynced(userId, db)
|
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
|
// Check if already checked in
|
||||||
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
const activeCheckIn = await db.getActiveCheckIn(userId)
|
||||||
if (activeCheckIn) {
|
if (activeCheckIn) {
|
||||||
return new NextResponse('Already checked in', { status: 400 })
|
return new NextResponse('Already checked in', { status: 400 })
|
||||||
}
|
}
|
||||||
@@ -35,7 +22,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const { type = 'gym', notes } = body
|
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)
|
return NextResponse.json(attendance)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Check-in error:', 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 })
|
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
|
||||||
const db = await getDatabase()
|
const db = await getDatabase()
|
||||||
const client = await db.getClientByUserId(userId)
|
|
||||||
|
|
||||||
if (!client) {
|
const activeCheckIn = await db.getActiveCheckIn(userId)
|
||||||
return new NextResponse('Client profile not found', { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
|
||||||
if (!activeCheckIn) {
|
if (!activeCheckIn) {
|
||||||
return new NextResponse('No active check-in found', { status: 404 })
|
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)
|
// Ensure user exists in DB (sync from Clerk if needed)
|
||||||
await ensureUserSynced(userId, db)
|
await ensureUserSynced(userId, db)
|
||||||
|
|
||||||
let client = await db.getClientByUserId(userId)
|
const history = await db.getAttendanceHistory(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)
|
|
||||||
return NextResponse.json(history)
|
return NextResponse.json(history)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('History error:', error)
|
console.error('History error:', error)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getDatabase } from '@/lib/database';
|
|||||||
// POST - Mark goal as complete
|
// POST - Mark goal as complete
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getDatabase } from '@/lib/database';
|
|||||||
// GET - Get specific goal
|
// GET - Get specific goal
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
@@ -40,7 +40,7 @@ export async function GET(
|
|||||||
// PUT - Update goal
|
// PUT - Update goal
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
@@ -82,7 +82,7 @@ export async function PUT(
|
|||||||
// DELETE - Delete goal
|
// DELETE - Delete goal
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
|
|||||||
@@ -19,7 +19,52 @@ export async function GET(request: NextRequest) {
|
|||||||
users.map(async (user) => {
|
users.map(async (user) => {
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
const client = await db.getClientByUserId(user.id);
|
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
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
|||||||
@@ -25,11 +25,34 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<ClerkProvider>
|
<ClerkProvider>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>
|
<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 bg-slate-50">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 ml-64 p-8">
|
<main className="flex-1 ml-64">
|
||||||
{children}
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+77
-48
@@ -46,56 +46,85 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-slate-100">
|
||||||
<div>
|
<div className="space-y-8 max-w-7xl">
|
||||||
<h2 className="text-3xl font-bold text-slate-900">Dashboard</h2>
|
{/* Hero Section */}
|
||||||
<p className="text-slate-500 mt-2">Welcome back, here's what's happening today.</p>
|
<div className="pt-8 pb-4">
|
||||||
</div>
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<img
|
||||||
<StatsCard
|
src="/nextform-logo.png"
|
||||||
title="Total Users"
|
alt="NextForm"
|
||||||
value={loading ? "..." : stats.totalUsers}
|
className="h-20 w-20 object-contain"
|
||||||
change="+12%" // Placeholder for now as we don't track historical growth yet
|
/>
|
||||||
trend="up"
|
<div className="flex items-center gap-3">
|
||||||
icon={Users}
|
<div className="h-12 w-1 bg-gradient-to-b from-blue-600 to-cyan-600 rounded-full"></div>
|
||||||
color="blue"
|
<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
|
||||||
<StatsCard
|
</h1>
|
||||||
title="Active Clients"
|
</div>
|
||||||
value={loading ? "..." : stats.activeClients}
|
</div>
|
||||||
change="+5%"
|
<p className="text-lg text-gray-600 ml-4">Performance metrics & athlete insights</p>
|
||||||
trend="up"
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
{/* Stats Grid */}
|
||||||
<h3 className="text-xl font-bold text-slate-900 mb-6">Quick Analytics</h3>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
<AnalyticsDashboard />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useUser } from "@clerk/nextjs";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { Home, Users, BarChart3, User, Brain } from "lucide-react";
|
import { Home, Users, BarChart3, User, Brain } from "lucide-react";
|
||||||
import { SignedIn, UserButton } from "@clerk/nextjs";
|
import { SignedIn, UserButton } from "@clerk/nextjs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -52,9 +52,16 @@ export function Navigation(): ReactElement {
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation Items */}
|
{/* Navigation Items */}
|
||||||
|
|||||||
@@ -67,74 +67,75 @@ export function AnalyticsDashboard() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<h2 className="text-2xl font-bold">Analytics Dashboard</h2>
|
{/* 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="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">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<p className="text-xs uppercase tracking-wide font-bold text-white mb-2">Total Revenue</p>
|
||||||
<Card>
|
<div className="flex items-baseline gap-2">
|
||||||
<CardContent>
|
<div className="text-2xl font-black text-white">${totalRevenue.toLocaleString()}</div>
|
||||||
<div className="text-center">
|
<span className="text-xs text-white font-semibold">ytd</span>
|
||||||
<div className="text-3xl font-bold text-blue-600">{totalUsers}</div>
|
</div>
|
||||||
<div className="text-gray-600">Total Users</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<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">
|
||||||
<CardContent>
|
<p className="text-xs uppercase tracking-wide font-bold text-white mb-2">Active Members</p>
|
||||||
<div className="text-center">
|
<div className="flex items-baseline gap-2">
|
||||||
<div className="text-3xl font-bold text-green-600">${totalRevenue.toLocaleString()}</div>
|
<div className="text-2xl font-black text-white">{activeMembers}</div>
|
||||||
<div className="text-gray-600">Total Revenue</div>
|
<span className="text-xs text-white font-semibold">members</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts - 3 Columns Horizontal */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
<Card>
|
<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">
|
||||||
<CardHeader>
|
<div className="mb-3">
|
||||||
<h3 className="text-lg font-semibold">User Growth</h3>
|
<h3 className="text-sm font-bold text-white">User Growth Trend</h3>
|
||||||
</CardHeader>
|
<p className="text-xs text-blue-100">Last 6 months performance</p>
|
||||||
<CardContent>
|
</div>
|
||||||
|
<div className="h-48 overflow-auto">
|
||||||
<UserGrowthChart data={userGrowthData} />
|
<UserGrowthChart data={userGrowthData} />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<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">
|
||||||
<CardHeader>
|
<div className="mb-3">
|
||||||
<h3 className="text-lg font-semibold">Membership Distribution</h3>
|
<h3 className="text-sm font-bold text-white">Membership Mix</h3>
|
||||||
</CardHeader>
|
<p className="text-xs text-amber-100">Distribution breakdown</p>
|
||||||
<CardContent>
|
</div>
|
||||||
|
<div className="h-48 overflow-auto">
|
||||||
<MembershipDistributionChart data={membershipData} />
|
<MembershipDistributionChart data={membershipData} />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
||||||
<CardHeader>
|
<div className="mb-3">
|
||||||
<h3 className="text-lg font-semibold">Monthly Revenue</h3>
|
<h3 className="text-sm font-bold text-white">Revenue Stream</h3>
|
||||||
</CardHeader>
|
<p className="text-xs text-pink-100">Monthly earnings</p>
|
||||||
<CardContent>
|
</div>
|
||||||
<RevenueChart data={revenueData} />
|
<div className="h-48 overflow-auto">
|
||||||
</CardContent>
|
<RevenueChart data={revenueData} />
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -65,14 +65,25 @@ export function Sidebar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
<div className="p-6 border-b border-slate-800">
|
{/* Logo Section */}
|
||||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
|
<div className="p-6 border-b border-slate-800/50">
|
||||||
FitAI Admin
|
<div className="flex items-center gap-3 justify-center">
|
||||||
</h1>
|
<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>
|
</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) => {
|
{menuItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = pathname === item.href;
|
const isActive = pathname === item.href;
|
||||||
@@ -81,26 +92,30 @@ export function Sidebar() {
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group relative overflow-hidden ${isActive
|
||||||
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
|
? "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 hover:text-white"}`}
|
: "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"} />
|
{isActive && <div className="absolute inset-0 bg-white/10 blur-xl"></div>}
|
||||||
<span className="font-medium">{label}</span>
|
<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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-slate-800">
|
{/* User Section */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-slate-800/50">
|
<div className="p-4 border-t border-slate-800/50 bg-slate-800/20">
|
||||||
<UserButton afterSignOutUrl="/" />
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">
|
<p className="text-sm font-semibold text-white truncate">
|
||||||
{user?.fullName || "Admin User"}
|
{user?.fullName || "Admin"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-400 truncate">
|
<p className="text-xs text-slate-400 truncate">
|
||||||
{user?.primaryEmailAddress?.emailAddress}
|
{user?.emailAddresses[0]?.emailAddress || "admin@fitai.com"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,38 +12,61 @@ interface StatsCardProps {
|
|||||||
|
|
||||||
export function StatsCard({ title, value, change, trend, icon: Icon, color = "blue" }: StatsCardProps) {
|
export function StatsCard({ title, value, change, trend, icon: Icon, color = "blue" }: StatsCardProps) {
|
||||||
const colorStyles = {
|
const colorStyles = {
|
||||||
blue: "bg-blue-50 text-blue-600",
|
blue: {
|
||||||
green: "bg-green-50 text-green-600",
|
bg: "from-blue-700 via-cyan-500 to-blue-600",
|
||||||
purple: "bg-purple-50 text-purple-600",
|
text: "text-white",
|
||||||
orange: "bg-orange-50 text-orange-600",
|
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 (
|
return (
|
||||||
<Card>
|
<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-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2.5 pt-5 px-5">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className={`text-xs font-bold uppercase tracking-wider ${styles.text} leading-tight max-w-[70%]`}>
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className={`p-2 rounded-lg ${colorStyles[color]}`}>
|
<div className={`p-1.5 rounded-lg ${styles.icon} flex-shrink-0`}>
|
||||||
<Icon size={16} />
|
<Icon size={16} strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-5 pb-5 space-y-2.5">
|
||||||
<div className="text-2xl font-bold">{value}</div>
|
<div className={`text-2xl font-black ${styles.text}`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
{change && (
|
{change && (
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<div className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className={`font-medium ${trend === "up"
|
className={`inline-flex items-center gap-0.5 font-bold px-2 py-0.5 rounded-md text-xs tracking-wide ${styles.badge}`}
|
||||||
? "text-green-600"
|
|
||||||
: trend === "down"
|
|
||||||
? "text-red-600"
|
|
||||||
: "text-slate-600"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{change}
|
{trend === "up" ? "↑" : trend === "down" ? "↓" : "→"} {change}
|
||||||
</span>{" "}
|
</span>
|
||||||
vs last month
|
<span className="text-xs text-gray-600">vs month</span>
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export Button component with lowercase filename for compatibility
|
||||||
|
export { Button } from './Button';
|
||||||
@@ -1,56 +1,23 @@
|
|||||||
import * as React from "react";
|
import React from 'react'
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary'
|
||||||
const buttonVariants = cva(
|
children: React.ReactNode
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-10 px-4 py-2",
|
|
||||||
sm: "h-9 rounded-md px-3",
|
|
||||||
lg: "h-11 rounded-md px-8",
|
|
||||||
icon: "h-10 w-10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
export function Button({ variant = 'primary', children, className = '', ...props }: ButtonProps) {
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors'
|
||||||
const Comp = asChild ? Slot : "button";
|
const variantClasses = {
|
||||||
return (
|
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||||
<Comp
|
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
}
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Button.displayName = "Button";
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export Card components with lowercase filename for compatibility
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card';
|
||||||
@@ -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<
|
export function Card({ children, className = '' }: CardProps) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||||
>(({ className, ...props }, ref) => (
|
{children}
|
||||||
<div
|
</div>
|
||||||
ref={ref}
|
)
|
||||||
className={cn(
|
}
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Card.displayName = "Card"
|
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
export function CardHeader({ children, className = '' }: CardProps) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div className={`mb-4 ${className}`}>
|
||||||
>(({ className, ...props }, ref) => (
|
{children}
|
||||||
<div
|
</div>
|
||||||
ref={ref}
|
)
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
}
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardHeader.displayName = "CardHeader"
|
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
export function CardContent({ children, className = '' }: CardProps) {
|
||||||
HTMLParagraphElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
<div className={className}>
|
||||||
>(({ className, ...props }, ref) => (
|
{children}
|
||||||
<h3
|
</div>
|
||||||
ref={ref}
|
)
|
||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
}
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardTitle.displayName = "CardTitle"
|
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
export function CardTitle({ children, className = '' }: CardProps) {
|
||||||
HTMLParagraphElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
<h2 className={`text-lg font-semibold ${className}`}>
|
||||||
>(({ className, ...props }, ref) => (
|
{children}
|
||||||
<p
|
</h2>
|
||||||
ref={ref}
|
)
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
}
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardDescription.displayName = "CardDescription"
|
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
export function CardDescription({ children, className = '' }: CardProps) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<p className={`text-sm text-gray-600 ${className}`}>
|
||||||
>(({ className, ...props }, ref) => (
|
{children}
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
</p>
|
||||||
))
|
)
|
||||||
CardContent.displayName = "CardContent"
|
}
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
export function CardFooter({ children, className = '' }: CardProps) {
|
||||||
HTMLDivElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div className={`mt-4 ${className}`}>
|
||||||
>(({ className, ...props }, ref) => (
|
{children}
|
||||||
<div
|
</div>
|
||||||
ref={ref}
|
)
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
}
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardFooter.displayName = "CardFooter"
|
|
||||||
|
|
||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface Recommendation {
|
interface Recommendation {
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ import { formatDate } from "@/lib/utils";
|
|||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
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 {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -17,6 +31,8 @@ interface User {
|
|||||||
role: string;
|
role: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
isCheckedIn?: boolean;
|
||||||
|
checkInTime?: Date;
|
||||||
client?: {
|
client?: {
|
||||||
id: string;
|
id: string;
|
||||||
membershipType: string;
|
membershipType: string;
|
||||||
@@ -152,6 +168,27 @@ export function UserGrid({
|
|||||||
},
|
},
|
||||||
minWidth: 120,
|
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",
|
headerName: "Join Date",
|
||||||
valueGetter: (params) =>
|
valueGetter: (params) =>
|
||||||
@@ -228,45 +265,72 @@ export function UserGrid({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center mb-4">
|
{/* Search and Actions Bar */}
|
||||||
<input
|
<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">
|
||||||
type="text"
|
<div className="w-full sm:w-auto flex-1">
|
||||||
placeholder="Search users..."
|
<div className="relative">
|
||||||
className="border border-gray-300 rounded px-4 py-2"
|
<svg className="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
value={searchQuery}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
</svg>
|
||||||
/>
|
<input
|
||||||
<div className="flex gap-2">
|
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
|
<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}
|
onClick={handleEdit}
|
||||||
disabled={selectedUsers.length !== 1}
|
disabled={selectedUsers.length !== 1}
|
||||||
>
|
>
|
||||||
Edit User
|
✏️ Edit
|
||||||
</button>
|
</button>
|
||||||
<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}
|
onClick={handleDelete}
|
||||||
disabled={selectedUsers.length !== 1}
|
disabled={selectedUsers.length !== 1}
|
||||||
>
|
>
|
||||||
Delete User
|
🗑️ Delete
|
||||||
</button>
|
</button>
|
||||||
<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}
|
onClick={handleBulkDelete}
|
||||||
disabled={selectedUsers.length === 0}
|
disabled={selectedUsers.length === 0}
|
||||||
>
|
>
|
||||||
Bulk Delete
|
⚡ Bulk
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="ag-theme-alpine"
|
{/* Table */}
|
||||||
style={{ height: "600px", width: "100%" }}
|
<div className="bg-white rounded-2xl shadow-lg border border-slate-200 overflow-hidden">
|
||||||
>
|
<div
|
||||||
<AgGridReact<User> {...gridOptions} ref={gridRef} />
|
className="ag-theme-alpine"
|
||||||
|
style={{ height: "600px", width: "100%" }}
|
||||||
|
>
|
||||||
|
<AgGridReact<User> {...gridOptions} ref={gridRef} />
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { UserGrid } from "@/components/users/UserGrid";
|
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";
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -13,6 +13,11 @@ interface User {
|
|||||||
role: string;
|
role: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
isCheckedIn?: boolean;
|
||||||
|
checkInTime?: Date;
|
||||||
|
lastCheckInTime?: Date;
|
||||||
|
checkInsThisWeek?: number;
|
||||||
|
checkInsThisMonth?: number;
|
||||||
client?: {
|
client?: {
|
||||||
id: string;
|
id: string;
|
||||||
membershipType: string;
|
membershipType: string;
|
||||||
@@ -200,92 +205,106 @@ export function UserManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
{/* Header Section */}
|
||||||
<h2 className="text-2xl font-bold">User Management</h2>
|
<div>
|
||||||
<div className="flex gap-2">
|
<h3 className="text-lg font-bold text-gray-900">User Management</h3>
|
||||||
<Button
|
<p className="text-sm text-gray-600 mt-1">Manage and monitor your fitness clients</p>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
{/* Filter Buttons */}
|
||||||
<div className="text-sm text-gray-600">
|
<div className="flex flex-wrap gap-2">
|
||||||
Showing {users.length} users
|
<Button
|
||||||
{selectedUser && (
|
variant={filter === "all" ? "primary" : "secondary"}
|
||||||
<span className="ml-4 text-blue-600">
|
onClick={() => setFilter("all")}
|
||||||
Selected: {selectedUser.firstName} {selectedUser.lastName}
|
className="text-xs"
|
||||||
</span>
|
>
|
||||||
)}
|
All Users
|
||||||
</div>
|
</Button>
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button variant="secondary" onClick={handleRefresh}>
|
variant={filter === "client" ? "primary" : "secondary"}
|
||||||
Refresh
|
onClick={() => setFilter("client")}
|
||||||
</Button>
|
className="text-xs"
|
||||||
<Button variant="secondary" onClick={handleExport}>
|
>
|
||||||
Export CSV
|
Clients
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<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>
|
</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>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<UserGrid
|
<UserGrid
|
||||||
@@ -444,7 +463,7 @@ export function UserManagement() {
|
|||||||
</a>
|
</a>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium mb-2">Basic Information</h4>
|
<h4 className="font-medium mb-2">Basic Information</h4>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
@@ -500,6 +519,28 @@ export function UserManagement() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -106,19 +106,61 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId);
|
CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Attendance table
|
// Attendance table migration: change from clientId to userId
|
||||||
this.db.exec(`
|
// Check if old table exists and migrate
|
||||||
CREATE TABLE IF NOT EXISTS attendance (
|
const tableInfo = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'").get() as any;
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
clientId TEXT NOT NULL,
|
if (tableInfo) {
|
||||||
checkInTime DATETIME NOT NULL,
|
// Check if table has clientId column (old schema)
|
||||||
checkOutTime DATETIME,
|
const columns = this.db.prepare("PRAGMA table_info(attendance)").all() as any[];
|
||||||
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
|
const hasClientId = columns.some((col: any) => col.name === 'clientId');
|
||||||
notes TEXT,
|
|
||||||
createdAt DATETIME NOT NULL,
|
if (hasClientId) {
|
||||||
FOREIGN KEY (clientId) REFERENCES clients (id) ON DELETE CASCADE
|
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
|
// Recommendations table
|
||||||
// Removed DROP TABLE to persist data. Schema is now stable.
|
// 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 (userId) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
|
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
|
||||||
)
|
)
|
||||||
`)
|
`);
|
||||||
|
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
|
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
|
||||||
`)
|
`);
|
||||||
|
|
||||||
this.db.exec(`
|
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);
|
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
|
||||||
`)
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// User operations
|
// User operations
|
||||||
@@ -389,7 +431,7 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attendance operations
|
// 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')
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
const id = Math.random().toString(36).substr(2, 9)
|
const id = Math.random().toString(36).substr(2, 9)
|
||||||
@@ -397,7 +439,7 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
|
|
||||||
const attendance: Attendance = {
|
const attendance: Attendance = {
|
||||||
id,
|
id,
|
||||||
clientId,
|
userId,
|
||||||
checkInTime: now,
|
checkInTime: now,
|
||||||
type,
|
type,
|
||||||
notes,
|
notes,
|
||||||
@@ -405,20 +447,23 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare(
|
const stmt = this.db.prepare(
|
||||||
`INSERT INTO attendance(id, clientId, checkInTime, type, notes, createdAt)
|
`INSERT INTO attendance(id, userId, checkInTime, type, notes, createdAt)
|
||||||
VALUES(?, ?, ?, ?, ?, ?)`
|
VALUES(?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
|
|
||||||
stmt.run(
|
stmt.run(
|
||||||
attendance.id, attendance.clientId, attendance.checkInTime.toISOString(),
|
attendance.id, attendance.userId, attendance.checkInTime.toISOString(),
|
||||||
attendance.type, attendance.notes, attendance.createdAt.toISOString()
|
attendance.type, attendance.notes, attendance.createdAt.toISOString()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update client last visit
|
// Update client last visit if user is a client
|
||||||
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
|
const client = await this.getClientByUserId(userId);
|
||||||
now.toISOString(),
|
if (client) {
|
||||||
clientId
|
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
|
||||||
)
|
now.toISOString(),
|
||||||
|
client.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return attendance
|
return attendance
|
||||||
}
|
}
|
||||||
@@ -440,10 +485,10 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
return row ? this.mapRowToAttendance(row) : null
|
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')
|
if (!this.db) throw new Error('Database not connected')
|
||||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? ORDER BY checkInTime DESC')
|
const stmt = this.db.prepare('SELECT * FROM attendance WHERE userId = ? ORDER BY checkInTime DESC')
|
||||||
const rows = stmt.all(clientId)
|
const rows = stmt.all(userId)
|
||||||
return rows.map(row => this.mapRowToAttendance(row))
|
return rows.map(row => this.mapRowToAttendance(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,10 +499,10 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
return rows.map(row => this.mapRowToAttendance(row))
|
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')
|
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 stmt = this.db.prepare('SELECT * FROM attendance WHERE userId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
|
||||||
const row = stmt.get(clientId)
|
const row = stmt.get(userId)
|
||||||
return row ? this.mapRowToAttendance(row) : null
|
return row ? this.mapRowToAttendance(row) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +554,7 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
private mapRowToAttendance(row: any): Attendance {
|
private mapRowToAttendance(row: any): Attendance {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
clientId: row.clientId,
|
userId: row.userId,
|
||||||
checkInTime: new Date(row.checkInTime),
|
checkInTime: new Date(row.checkInTime),
|
||||||
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export interface FitnessProfile {
|
|||||||
|
|
||||||
export interface Attendance {
|
export interface Attendance {
|
||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
userId: string;
|
||||||
checkInTime: Date;
|
checkInTime: Date;
|
||||||
checkOutTime?: Date;
|
checkOutTime?: Date;
|
||||||
type: "gym" | "class" | "personal_training";
|
type: "gym" | "class" | "personal_training";
|
||||||
@@ -121,14 +121,14 @@ export interface IDatabase {
|
|||||||
|
|
||||||
// Attendance operations
|
// Attendance operations
|
||||||
checkIn(
|
checkIn(
|
||||||
clientId: string,
|
userId: string,
|
||||||
type: "gym" | "class" | "personal_training",
|
type: "gym" | "class" | "personal_training",
|
||||||
notes?: string,
|
notes?: string,
|
||||||
): Promise<Attendance>;
|
): Promise<Attendance>;
|
||||||
checkOut(attendanceId: string): Promise<Attendance | null>;
|
checkOut(attendanceId: string): Promise<Attendance | null>;
|
||||||
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
|
getAttendanceHistory(userId: string): Promise<Attendance[]>;
|
||||||
getAllAttendance(): Promise<Attendance[]>;
|
getAllAttendance(): Promise<Attendance[]>;
|
||||||
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
|
getActiveCheckIn(userId: string): Promise<Attendance | null>;
|
||||||
|
|
||||||
// Recommendation operations
|
// Recommendation operations
|
||||||
createRecommendation(
|
createRecommendation(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
|
|||||||
+11
-4
@@ -15,21 +15,28 @@
|
|||||||
"**/*"
|
"**/*"
|
||||||
],
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"infoPlist": {
|
||||||
|
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
},
|
||||||
|
"permissions": [
|
||||||
|
"CAMERA"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font"
|
"expo-font",
|
||||||
|
"expo-barcode-scanner"
|
||||||
],
|
],
|
||||||
"scheme": "fitai"
|
"scheme": "fitai"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+33
-1
@@ -20,13 +20,15 @@
|
|||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"expo": "~54.0.23",
|
"expo": "~54.0.23",
|
||||||
"expo-auth-session": "^7.0.8",
|
"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-constants": "^18.0.10",
|
||||||
"expo-crypto": "^15.0.7",
|
"expo-crypto": "^15.0.7",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "^15.0.7",
|
"expo-haptics": "^15.0.7",
|
||||||
"expo-linear-gradient": "~15.0.7",
|
"expo-linear-gradient": "~15.0.7",
|
||||||
"expo-linking": "~8.0.0",
|
"expo-linking": "~8.0.0",
|
||||||
|
"expo-location": "~17.0.1",
|
||||||
"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",
|
||||||
@@ -7203,6 +7205,18 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-camera": {
|
||||||
"version": "17.0.9",
|
"version": "17.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz",
|
||||||
@@ -7282,6 +7296,15 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo-linear-gradient": {
|
||||||
"version": "15.0.7",
|
"version": "15.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz",
|
||||||
@@ -7307,6 +7330,15 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.22",
|
"version": "3.0.22",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz",
|
||||||
|
|||||||
@@ -26,13 +26,15 @@
|
|||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"expo": "~54.0.23",
|
"expo": "~54.0.23",
|
||||||
"expo-auth-session": "^7.0.8",
|
"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-constants": "^18.0.10",
|
||||||
"expo-crypto": "^15.0.7",
|
"expo-crypto": "^15.0.7",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "^15.0.7",
|
"expo-haptics": "^15.0.7",
|
||||||
"expo-linear-gradient": "~15.0.7",
|
"expo-linear-gradient": "~15.0.7",
|
||||||
"expo-linking": "~8.0.0",
|
"expo-linking": "~8.0.0",
|
||||||
|
"expo-location": "~17.0.1",
|
||||||
"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",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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 { attendanceApi, Attendance } from '../../api/attendance'
|
||||||
import { theme } from '../../styles/theme'
|
import { theme } from '../../styles/theme'
|
||||||
import { Animated } from 'react-native'
|
import { Animated } from 'react-native'
|
||||||
|
import { GeofenceStatus } from '../../components/GeofenceStatus'
|
||||||
|
|
||||||
export default function AttendanceScreen() {
|
export default function AttendanceScreen() {
|
||||||
const { getToken, userId } = useAuth()
|
const { getToken, userId } = useAuth()
|
||||||
@@ -111,6 +112,9 @@ export default function AttendanceScreen() {
|
|||||||
<Text style={styles.subtitle}>Track your gym visits</Text>
|
<Text style={styles.subtitle}>Track your gym visits</Text>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
||||||
|
{/* Geofencing Status Card */}
|
||||||
|
<GeofenceStatus />
|
||||||
|
|
||||||
<View style={styles.actionContainer}>
|
<View style={styles.actionContainer}>
|
||||||
{activeCheckIn ? (
|
{activeCheckIn ? (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { QuickActionGrid } from "../../components/QuickActionGrid";
|
|||||||
import { TrackMealModal } from "../../components/TrackMealModal";
|
import { TrackMealModal } from "../../components/TrackMealModal";
|
||||||
import { AddWaterModal } from "../../components/AddWaterModal";
|
import { AddWaterModal } from "../../components/AddWaterModal";
|
||||||
import { HydrationWidget } from "../../components/HydrationWidget";
|
import { HydrationWidget } from "../../components/HydrationWidget";
|
||||||
|
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
||||||
|
import { PerformanceMetrics } from "../../components/PerformanceMetrics";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
@@ -16,6 +18,7 @@ export default function HomeScreen() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
|
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
|
||||||
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
|
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
|
||||||
|
const [scanFoodModalVisible, setScanFoodModalVisible] = useState(false);
|
||||||
const [calories, setCalories] = useState(0);
|
const [calories, setCalories] = useState(0);
|
||||||
const [waterIntake, setWaterIntake] = useState(0);
|
const [waterIntake, setWaterIntake] = useState(0);
|
||||||
|
|
||||||
@@ -51,6 +54,11 @@ export default function HomeScreen() {
|
|||||||
setWaterIntake(0);
|
setWaterIntake(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddScannedFood = (scannedCalories: number) => {
|
||||||
|
setCalories(prev => prev + scannedCalories);
|
||||||
|
setScanFoodModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
const resetAllCounters = async () => {
|
const resetAllCounters = async () => {
|
||||||
setCalories(0);
|
setCalories(0);
|
||||||
setWaterIntake(0);
|
setWaterIntake(0);
|
||||||
@@ -102,9 +110,15 @@ export default function HomeScreen() {
|
|||||||
>
|
>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
<Text style={styles.greeting}>{getGreeting()},</Text>
|
<Image
|
||||||
<Text style={styles.name}>{user?.firstName || "Athlete"}</Text>
|
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>
|
||||||
<View style={styles.avatarContainer}>
|
<View style={styles.avatarContainer}>
|
||||||
{user?.imageUrl ? (
|
{user?.imageUrl ? (
|
||||||
@@ -124,6 +138,9 @@ export default function HomeScreen() {
|
|||||||
duration={45}
|
duration={45}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Performance Metrics */}
|
||||||
|
<PerformanceMetrics />
|
||||||
|
|
||||||
{/* Hydration Widget */}
|
{/* Hydration Widget */}
|
||||||
<HydrationWidget
|
<HydrationWidget
|
||||||
current={waterIntake}
|
current={waterIntake}
|
||||||
@@ -134,6 +151,7 @@ export default function HomeScreen() {
|
|||||||
<QuickActionGrid
|
<QuickActionGrid
|
||||||
onTrackMealPress={() => setTrackMealModalVisible(true)}
|
onTrackMealPress={() => setTrackMealModalVisible(true)}
|
||||||
onAddWaterPress={() => setAddWaterModalVisible(true)}
|
onAddWaterPress={() => setAddWaterModalVisible(true)}
|
||||||
|
onScanFoodPress={() => setScanFoodModalVisible(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TrackMealModal
|
<TrackMealModal
|
||||||
@@ -150,6 +168,12 @@ export default function HomeScreen() {
|
|||||||
onResetData={handleResetWater}
|
onResetData={handleResetWater}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ScanFoodModal
|
||||||
|
visible={scanFoodModalVisible}
|
||||||
|
onClose={() => setScanFoodModalVisible(false)}
|
||||||
|
onAddFood={handleAddScannedFood}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Recent Activity Section */}
|
{/* Recent Activity Section */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProp
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={theme.gradients.dark}
|
colors={['#0c4a6e', '#0369a1', '#0ea5e9']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
style={[styles.card, theme.shadows.medium]}
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#e0f2fe', '#dbeafe']} // Light blue background
|
colors={['#0369a1', '#0ea5e9', '#06b6d4']}
|
||||||
style={[styles.card, theme.shadows.subtle]}
|
style={[styles.card, theme.shadows.subtle]}
|
||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 {
|
interface QuickActionGridProps {
|
||||||
onTrackMealPress?: () => void;
|
onTrackMealPress?: () => void;
|
||||||
onAddWaterPress?: () => void;
|
onAddWaterPress?: () => void;
|
||||||
|
onScanFoodPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActionGridProps) {
|
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||||
@@ -67,6 +68,7 @@ export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActi
|
|||||||
icon="scan"
|
icon="scan"
|
||||||
label="Scan Food"
|
label="Scan Food"
|
||||||
gradient={theme.gradients.purple}
|
gradient={theme.gradients.purple}
|
||||||
|
onPress={onScanFoodPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</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,5 +1,5 @@
|
|||||||
export const API_BASE_URL = __DEV__
|
export const API_BASE_URL = __DEV__
|
||||||
? 'https://694d46f62d87.ngrok-free.app'
|
? 'https://8679109544e4.ngrok-free.app'
|
||||||
: 'https://your-production-url.com'
|
: 'https://your-production-url.com'
|
||||||
|
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
import * as WebBrowser from "expo-web-browser";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export const useWarmUpBrowser = () => {
|
export const useWarmUpBrowser = () => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Warm up the android browser to improve UX
|
// Warm up the android browser to improve UX
|
||||||
// https://docs.expo.dev/guides/authentication/#improving-user-experience
|
// https://docs.expo.dev/guides/authentication/#improving-user-experience
|
||||||
void WebBrowser.warmUpAsync();
|
// Only available on native platforms (iOS/Android), not on web
|
||||||
return () => {
|
if (Platform.OS !== "web") {
|
||||||
void WebBrowser.coolDownAsync();
|
void WebBrowser.warmUpAsync();
|
||||||
};
|
return () => {
|
||||||
|
void WebBrowser.coolDownAsync();
|
||||||
|
};
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -64,6 +64,11 @@ export const theme = {
|
|||||||
forest: ['#10b981', '#059669'] as const,
|
forest: ['#10b981', '#059669'] as const,
|
||||||
lavender: ['#a78bfa', '#ec4899'] as const,
|
lavender: ['#a78bfa', '#ec4899'] as const,
|
||||||
dark: ['#1e293b', '#0f172a'] 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
|
// Shadow System
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
+171
-12
@@ -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,
|
## Project Structure
|
||||||
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.
|
|
||||||
|
|
||||||
# phase 2
|
```
|
||||||
we will be tracking user inputs via manual input and devices, backend will analyze data and propose
|
fitai/
|
||||||
excercises etc.
|
├── 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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user