Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
Regular → Executable
BIN
Binary file not shown.
Whitespace-only changes.
@@ -1,11 +1,10 @@
|
|||||||
const { pathsToModuleNameMapper } = require('ts-jest');
|
|
||||||
const { compilerOptions } = require('./tsconfig');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
preset: 'ts-jest',
|
preset: 'ts-jest',
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }),
|
moduleNameMapping: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
testMatch: [
|
testMatch: [
|
||||||
'**/__tests__/**/*.(ts|tsx|js)',
|
'**/__tests__/**/*.(ts|tsx|js)',
|
||||||
'**/*.(test|spec).(ts|tsx|js)',
|
'**/*.(test|spec).(ts|tsx|js)',
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
require('@testing-library/jest-dom')
|
import '@testing-library/jest-dom'
|
||||||
Generated
+8
-780
File diff suppressed because it is too large.
Load diff
@@ -26,11 +26,10 @@
|
|||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "12.4.1",
|
"better-sqlite3": "^11.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.1",
|
"next": "^16.0.1",
|
||||||
@@ -50,15 +49,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/jest": "^30.0.0",
|
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "19.2.2",
|
"@types/react": "19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "^16.0.1",
|
"eslint-config-next": "^16.0.1",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
|
||||||
"ts-jest": "^29.4.6",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
|
|
||||||
import { getDatabase } from '../src/lib/database';
|
|
||||||
import { DrizzleDatabase } from '../src/lib/database/drizzle';
|
|
||||||
|
|
||||||
async function checkUserRole() {
|
|
||||||
const db = await getDatabase();
|
|
||||||
const users = await db.getAllUsers();
|
|
||||||
console.log('Users found:', users.length);
|
|
||||||
users.forEach(u => {
|
|
||||||
console.log(`User: ${u.email}, Role: ${u.role}, ID: ${u.id}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
checkUserRole().catch(console.error);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
|
|
||||||
import { getDatabase } from '../src/lib/database';
|
|
||||||
|
|
||||||
async function makeAdmin(email: string) {
|
|
||||||
const db = await getDatabase();
|
|
||||||
const user = await db.getUserByEmail(email);
|
|
||||||
if (!user) {
|
|
||||||
console.log('User not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await db.updateUser(user.id, { role: 'admin' });
|
|
||||||
console.log(`User ${email} updated to admin`);
|
|
||||||
}
|
|
||||||
|
|
||||||
makeAdmin('taratur@gmail.com').catch(console.error);
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
|
|
||||||
import 'dotenv/config';
|
|
||||||
import { getDatabase } from '../src/lib/database';
|
|
||||||
import { clerkClient } from '@clerk/nextjs/server';
|
|
||||||
|
|
||||||
async function syncAllUsers() {
|
|
||||||
console.log('Starting full user sync...');
|
|
||||||
const db = await getDatabase();
|
|
||||||
|
|
||||||
// Fetch all users from Clerk
|
|
||||||
// Note: pagination might be needed for large user bases, but for prototype this is fine.
|
|
||||||
const client = await clerkClient();
|
|
||||||
const response = await client.users.getUserList({ limit: 100 });
|
|
||||||
const clerkUsers = response.data;
|
|
||||||
|
|
||||||
console.log(`Found ${clerkUsers.length} users in Clerk.`);
|
|
||||||
|
|
||||||
for (const clerkUser of clerkUsers) {
|
|
||||||
const userId = clerkUser.id;
|
|
||||||
const email = clerkUser.emailAddresses[0]?.emailAddress;
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
console.warn(`User ${userId} has no email, skipping.`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await db.getUserById(userId);
|
|
||||||
if (existingUser) {
|
|
||||||
console.log(`User ${email} (${userId}) already exists.`);
|
|
||||||
// Optional: Update details if needed
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check by email to handle ID mismatches (e.g. from seeding)
|
|
||||||
const existingByEmail = await db.getUserByEmail(email);
|
|
||||||
if (existingByEmail) {
|
|
||||||
console.log(`User ${email} found with old ID ${existingByEmail.id}. Migrating to ${userId}...`);
|
|
||||||
await db.migrateUserId(existingByEmail.id, userId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Creating new user ${email} (${userId})...`);
|
|
||||||
await db.createUser({
|
|
||||||
id: userId,
|
|
||||||
email,
|
|
||||||
firstName: clerkUser.firstName || '',
|
|
||||||
lastName: clerkUser.lastName || '',
|
|
||||||
password: '', // Managed by Clerk
|
|
||||||
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
|
||||||
role: (clerkUser.publicMetadata.role as 'admin' | 'client' | 'superAdmin') || 'client'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Sync complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
syncAllUsers().catch(console.error);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "CommonJS",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"ts-node": {
|
|
||||||
"transpileOnly": true,
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "CommonJS"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
|
|
||||||
import { getDatabase } from '../src/lib/database/index.ts';
|
|
||||||
|
|
||||||
async function verifyDatabase() {
|
|
||||||
console.log('Starting database verification...');
|
|
||||||
const db = await getDatabase();
|
|
||||||
await db.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Create User
|
|
||||||
console.log('Creating test user...');
|
|
||||||
const userId = `test-user-${Date.now()}`;
|
|
||||||
const user = await db.createUser({
|
|
||||||
id: userId,
|
|
||||||
email: `test-${Date.now()}@example.com`,
|
|
||||||
firstName: 'Test',
|
|
||||||
lastName: 'User',
|
|
||||||
password: 'password123',
|
|
||||||
role: 'client',
|
|
||||||
phone: '1234567890'
|
|
||||||
});
|
|
||||||
console.log('User created:', user.id);
|
|
||||||
|
|
||||||
// 2. Create Client
|
|
||||||
console.log('Creating test client...');
|
|
||||||
const client = await db.createClient({
|
|
||||||
userId: user.id,
|
|
||||||
membershipType: 'basic',
|
|
||||||
membershipStatus: 'active',
|
|
||||||
joinDate: new Date()
|
|
||||||
});
|
|
||||||
console.log('Client created:', client.id);
|
|
||||||
|
|
||||||
// 3. Create Fitness Profile
|
|
||||||
console.log('Creating fitness profile...');
|
|
||||||
const profile = await db.createFitnessProfile({
|
|
||||||
userId: user.id,
|
|
||||||
height: '180',
|
|
||||||
weight: '75',
|
|
||||||
age: '30',
|
|
||||||
gender: 'male',
|
|
||||||
activityLevel: 'moderately_active',
|
|
||||||
fitnessGoals: ['weight_loss'],
|
|
||||||
exerciseHabits: 'None',
|
|
||||||
dietHabits: 'None',
|
|
||||||
medicalConditions: 'None'
|
|
||||||
});
|
|
||||||
console.log('Fitness profile created for:', profile.userId);
|
|
||||||
|
|
||||||
// 4. Attendance Check-in
|
|
||||||
console.log('Checking in...');
|
|
||||||
const checkIn = await db.checkIn(user.id, 'gym', 'Test check-in');
|
|
||||||
console.log('Checked in:', checkIn.id);
|
|
||||||
|
|
||||||
// 5. Verify Active Check-in
|
|
||||||
const activeCheckIn = await db.getActiveCheckIn(user.id);
|
|
||||||
if (!activeCheckIn || activeCheckIn.id !== checkIn.id) {
|
|
||||||
throw new Error('Active check-in verification failed');
|
|
||||||
}
|
|
||||||
console.log('Active check-in verified');
|
|
||||||
|
|
||||||
// 6. Attendance Check-out
|
|
||||||
console.log('Checking out...');
|
|
||||||
const checkOut = await db.checkOut(checkIn.id);
|
|
||||||
console.log('Checked out:', checkOut?.checkOutTime);
|
|
||||||
|
|
||||||
// 7. Cleanup
|
|
||||||
console.log('Cleaning up...');
|
|
||||||
await db.deleteUser(user.id);
|
|
||||||
console.log('Cleanup complete');
|
|
||||||
|
|
||||||
console.log('✅ Verification successful!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Verification failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await db.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyDatabase();
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* @jest-environment node
|
|
||||||
*/
|
|
||||||
import { POST as checkIn } from '../check-in/route'
|
|
||||||
import { POST as checkOut } from '../check-out/route'
|
|
||||||
import { GET as history } from '../history/route'
|
|
||||||
import { NextRequest } from 'next/server'
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
jest.mock('@clerk/nextjs/server', () => ({
|
|
||||||
auth: jest.fn(() => Promise.resolve({ userId: 'test_user_id' })),
|
|
||||||
currentUser: jest.fn(() => Promise.resolve({ id: 'test_user_id', emailAddresses: [{ emailAddress: 'test@example.com' }] }))
|
|
||||||
}))
|
|
||||||
|
|
||||||
jest.mock('@/lib/sync-user', () => ({
|
|
||||||
ensureUserSynced: jest.fn()
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockDb = {
|
|
||||||
checkIn: jest.fn(),
|
|
||||||
checkOut: jest.fn(),
|
|
||||||
getAttendanceHistory: jest.fn(),
|
|
||||||
getActiveCheckIn: jest.fn(),
|
|
||||||
getUserById: jest.fn(),
|
|
||||||
createUser: jest.fn(),
|
|
||||||
getClientByUserId: jest.fn(),
|
|
||||||
createClient: jest.fn(),
|
|
||||||
getFitnessProfileByUserId: jest.fn(),
|
|
||||||
createFitnessProfile: jest.fn(),
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock('@/lib/database', () => ({
|
|
||||||
getDatabase: jest.fn(() => Promise.resolve(mockDb))
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('Attendance API', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/attendance/check-in', () => {
|
|
||||||
it('should successfully check in', async () => {
|
|
||||||
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
|
|
||||||
mockDb.getActiveCheckIn.mockResolvedValue(null)
|
|
||||||
mockDb.checkIn.mockResolvedValue({
|
|
||||||
id: 'attendance_id',
|
|
||||||
userId: 'test_user_id',
|
|
||||||
checkInTime: new Date(),
|
|
||||||
type: 'gym'
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost/api/attendance/check-in', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ type: 'gym', notes: 'Test check-in' })
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await checkIn(req)
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(data.id).toBe('attendance_id')
|
|
||||||
expect(data.userId).toBe('test_user_id')
|
|
||||||
expect(mockDb.checkIn).toHaveBeenCalledWith('test_user_id', 'gym', 'Test check-in')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fail if already checked in', async () => {
|
|
||||||
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
|
|
||||||
mockDb.getActiveCheckIn.mockResolvedValue({ id: 'existing_id' })
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost/api/attendance/check-in', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ type: 'gym' })
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await checkIn(req)
|
|
||||||
const text = await res.text()
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
expect(text).toBe('Already checked in')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/attendance/check-out', () => {
|
|
||||||
it('should successfully check out', async () => {
|
|
||||||
mockDb.getActiveCheckIn.mockResolvedValue({ id: 'attendance_id' })
|
|
||||||
mockDb.checkOut.mockResolvedValue({
|
|
||||||
id: 'attendance_id',
|
|
||||||
checkOutTime: new Date()
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost/api/attendance/check-out', {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await checkOut(req)
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(data.id).toBe('attendance_id')
|
|
||||||
expect(data.checkOutTime).toBeDefined()
|
|
||||||
expect(mockDb.checkOut).toHaveBeenCalledWith('attendance_id')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fail if not checked in', async () => {
|
|
||||||
mockDb.getActiveCheckIn.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost/api/attendance/check-out', {
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await checkOut(req)
|
|
||||||
const text = await res.text()
|
|
||||||
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
expect(text).toBe('No active check-in found')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GET /api/attendance/history', () => {
|
|
||||||
it('should return attendance history', async () => {
|
|
||||||
const historyData = [
|
|
||||||
{ id: '1', checkInTime: new Date() },
|
|
||||||
{ id: '2', checkInTime: new Date() }
|
|
||||||
]
|
|
||||||
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
|
|
||||||
mockDb.getAttendanceHistory.mockResolvedValue(historyData)
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost/api/attendance/history')
|
|
||||||
const res = await history(req)
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(data).toEqual(JSON.parse(JSON.stringify(historyData))) // Handle date serialization
|
|
||||||
expect(mockDb.getAttendanceHistory).toHaveBeenCalledWith('test_user_id')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -172,9 +172,9 @@ export async function POST(req: Request) {
|
|||||||
const recommendation = await db.createRecommendation({
|
const recommendation = await db.createRecommendation({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
fitnessProfileId: profile.id,
|
fitnessProfileId: profile.userId,
|
||||||
type: 'ai_plan',
|
type: 'ai_plan',
|
||||||
recommendationText: parsedResponse.recommendationText,
|
content: parsedResponse.recommendationText,
|
||||||
activityPlan: parsedResponse.activityPlan,
|
activityPlan: parsedResponse.activityPlan,
|
||||||
dietPlan: parsedResponse.dietPlan,
|
dietPlan: parsedResponse.dietPlan,
|
||||||
status: 'pending'
|
status: 'pending'
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export async function POST(request: Request) {
|
|||||||
userId,
|
userId,
|
||||||
fitnessProfileId,
|
fitnessProfileId,
|
||||||
type: 'ai_plan',
|
type: 'ai_plan',
|
||||||
recommendationText: recommendationText,
|
content: recommendationText,
|
||||||
activityPlan,
|
activityPlan,
|
||||||
dietPlan,
|
dietPlan,
|
||||||
status: status || 'pending'
|
status: status || 'pending'
|
||||||
@@ -90,7 +90,7 @@ export async function POST(request: Request) {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
type,
|
type,
|
||||||
recommendationText: content,
|
content,
|
||||||
status: status || 'pending'
|
status: status || 'pending'
|
||||||
})
|
})
|
||||||
return NextResponse.json(recommendation)
|
return NextResponse.json(recommendation)
|
||||||
@@ -122,8 +122,8 @@ export async function PUT(request: Request) {
|
|||||||
|
|
||||||
const updated = await db.updateRecommendation(id, {
|
const updated = await db.updateRecommendation(id, {
|
||||||
...(status && { status }),
|
...(status && { status }),
|
||||||
...(recommendationText && { recommendationText }),
|
...(recommendationText && { content: recommendationText }), // Map legacy field
|
||||||
...(content && { recommendationText: content }),
|
...(content && { content }),
|
||||||
...(activityPlan && { activityPlan }),
|
...(activityPlan && { activityPlan }),
|
||||||
...(dietPlan && { dietPlan })
|
...(dietPlan && { dietPlan })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export default function RecommendationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm mb-4">
|
<div className="space-y-2 text-sm mb-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Advice:</span> {rec.recommendationText}
|
<span className="font-semibold">Advice:</span> {rec.content}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Activity:</span> {rec.activityPlan}
|
<span className="font-semibold">Activity:</span> {rec.activityPlan}
|
||||||
|
|||||||
@@ -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,54 @@
|
|||||||
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 }
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"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 {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
||||||
recommendationText: string;
|
content: string;
|
||||||
activityPlan?: string;
|
activityPlan?: string;
|
||||||
dietPlan?: string;
|
dietPlan?: string;
|
||||||
status: "pending" | "completed" | "approved" | "rejected";
|
status: "pending" | "completed" | "approved" | "rejected";
|
||||||
@@ -97,7 +97,7 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-gray-700">{rec.recommendationText}</p>
|
<p className="text-sm font-medium">{rec.content}</p>
|
||||||
{rec.type === 'ai_plan' && (
|
{rec.type === 'ai_plan' && (
|
||||||
<div className="mt-2 text-xs text-gray-600 space-y-1">
|
<div className="mt-2 text-xs text-gray-600 space-y-1">
|
||||||
{rec.activityPlan && <p><span className="font-semibold">Activity:</span> {rec.activityPlan}</p>}
|
{rec.activityPlan && <p><span className="font-semibold">Activity:</span> {rec.activityPlan}</p>}
|
||||||
|
|||||||
@@ -224,14 +224,14 @@ export function UserGrid({
|
|||||||
const gridRef = React.useRef<AgGridReact<User>>(null);
|
const gridRef = React.useRef<AgGridReact<User>>(null);
|
||||||
|
|
||||||
const gridOptions = {
|
const gridOptions = {
|
||||||
theme: "legacy" as const,
|
theme: "legacy",
|
||||||
columnDefs,
|
columnDefs,
|
||||||
defaultColDef,
|
defaultColDef,
|
||||||
rowData: users,
|
rowData: users,
|
||||||
rowSelection: "multiple" as const,
|
rowSelection: "multiple",
|
||||||
onSelectionChanged: () => {
|
onSelectionChanged: () => {
|
||||||
const selectedNodes = gridRef.current?.api.getSelectedNodes();
|
const selectedNodes = gridRef.current?.api.getSelectedNodes();
|
||||||
const selectedData = selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) || [];
|
const selectedData = selectedNodes?.map((node) => node.data) || [];
|
||||||
setSelectedUsers(selectedData);
|
setSelectedUsers(selectedData);
|
||||||
if (selectedData.length === 1 && onUserSelect) {
|
if (selectedData.length === 1 && onUserSelect) {
|
||||||
onUserSelect(selectedData[0]);
|
onUserSelect(selectedData[0]);
|
||||||
@@ -265,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,9 +2,8 @@
|
|||||||
|
|
||||||
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 { Card, CardHeader, CardContent } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -206,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" ? "default" : "outline"}
|
|
||||||
onClick={() => setFilter("all")}
|
|
||||||
>
|
|
||||||
All Users
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filter === "client" ? "default" : "outline"}
|
|
||||||
onClick={() => selectedUser && handleEditUser(selectedUser)}
|
|
||||||
disabled={!selectedUser}
|
|
||||||
>
|
|
||||||
Edit User
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filter === "client" ? "default" : "outline"}
|
|
||||||
onClick={() => {
|
|
||||||
setEditForm({
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
email: "",
|
|
||||||
role: "client",
|
|
||||||
phone: "",
|
|
||||||
});
|
|
||||||
setSelectedUser(null);
|
|
||||||
setIsEditing(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Invite User
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filter === "client" ? "default" : "outline"}
|
|
||||||
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
|
|
||||||
disabled={!selectedUser}
|
|
||||||
>
|
|
||||||
Delete User
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filter === "client" ? "default" : "outline"}
|
|
||||||
onClick={() => setFilter("client")}
|
|
||||||
>
|
|
||||||
Clientsa
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filter === "trainer" ? "default" : "outline"}
|
|
||||||
onClick={() => setFilter("trainer")}
|
|
||||||
>
|
|
||||||
Trainers
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filter === "admin" ? "default" : "outline"}
|
|
||||||
onClick={() => setFilter("admin")}
|
|
||||||
>
|
|
||||||
Admins
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filter === "superAdmin" ? "default" : "outline"}
|
|
||||||
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="default" onClick={handleRefresh}>
|
variant={filter === "client" ? "primary" : "secondary"}
|
||||||
Refresh
|
onClick={() => setFilter("client")}
|
||||||
</Button>
|
className="text-xs"
|
||||||
<Button variant="default" 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
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { getDatabase } from '@/lib/database';
|
import { getDatabase } from '@/lib/database';
|
||||||
import type { FitnessGoal, FitnessProfile, Recommendation } from '@/lib/database/types';
|
import type { FitnessGoal } from '@/lib/database/types';
|
||||||
|
|
||||||
|
// Import types from database package
|
||||||
|
import type {
|
||||||
|
FitnessProfile as DBFitnessProfile,
|
||||||
|
Recommendation as DBRecommendation,
|
||||||
|
} from '@fitai/database';
|
||||||
|
|
||||||
export interface AIContext {
|
export interface AIContext {
|
||||||
profile: FitnessProfile;
|
profile: DBFitnessProfile;
|
||||||
activeGoals: FitnessGoal[];
|
activeGoals: FitnessGoal[];
|
||||||
completedGoals: FitnessGoal[];
|
completedGoals: FitnessGoal[];
|
||||||
recentRecommendations: Recommendation[];
|
recentRecommendations: DBRecommendation[];
|
||||||
progressSummary: {
|
progressSummary: {
|
||||||
goalsCompleted: number;
|
goalsCompleted: number;
|
||||||
goalsActive: number;
|
goalsActive: number;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function buildEnhancedPrompt(context: AIContext): string {
|
|||||||
- Weight: ${profile.weight || 'Not specified'} kg
|
- Weight: ${profile.weight || 'Not specified'} kg
|
||||||
- Age: ${profile.age || 'Not specified'}
|
- Age: ${profile.age || 'Not specified'}
|
||||||
- Gender: ${profile.gender || 'Not specified'}
|
- Gender: ${profile.gender || 'Not specified'}
|
||||||
- Primary Goal: ${profile.fitnessGoals?.[0] || 'General fitness'}
|
- Primary Goal: ${profile.fitnessGoal || 'General fitness'}
|
||||||
- Activity Level: ${profile.activityLevel || 'Not specified'}
|
- Activity Level: ${profile.activityLevel || 'Not specified'}
|
||||||
- Medical Conditions: ${profile.medicalConditions || 'None'}
|
- Medical Conditions: ${profile.medicalConditions || 'None'}
|
||||||
- Allergies: ${profile.allergies || 'None'}
|
- Allergies: ${profile.allergies || 'None'}
|
||||||
@@ -77,7 +77,7 @@ Generate a detailed daily recommendation for a user with the following profile:
|
|||||||
- Weight: ${profile.weight} kg
|
- Weight: ${profile.weight} kg
|
||||||
- Age: ${profile.age}
|
- Age: ${profile.age}
|
||||||
- Gender: ${profile.gender}
|
- Gender: ${profile.gender}
|
||||||
- Goal: ${profile.fitnessGoals?.[0]}
|
- Goal: ${profile.fitnessGoal}
|
||||||
- Activity Level: ${profile.activityLevel}
|
- Activity Level: ${profile.activityLevel}
|
||||||
- Medical Conditions: ${profile.medicalConditions || "None"}
|
- Medical Conditions: ${profile.medicalConditions || "None"}
|
||||||
- Injuries: ${profile.injuries || "None"}
|
- Injuries: ${profile.injuries || "None"}
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* @jest-environment node
|
|
||||||
*/
|
|
||||||
import { DrizzleDatabase } from '../drizzle'
|
|
||||||
import { DatabaseConfig } from '../types'
|
|
||||||
import Database from 'better-sqlite3'
|
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
||||||
import * as schema from '@fitai/database'
|
|
||||||
|
|
||||||
describe('DrizzleDatabase', () => {
|
|
||||||
let db: DrizzleDatabase
|
|
||||||
let sqlite: Database.Database
|
|
||||||
let drizzleDb: ReturnType<typeof drizzle>
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Create in-memory SQLite database
|
|
||||||
sqlite = new Database(':memory:')
|
|
||||||
drizzleDb = drizzle(sqlite, { schema })
|
|
||||||
|
|
||||||
const config: DatabaseConfig = {
|
|
||||||
type: 'sqlite',
|
|
||||||
connection: { filename: ':memory:' },
|
|
||||||
options: { logging: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize DrizzleDatabase with the in-memory db
|
|
||||||
db = new DrizzleDatabase(config, drizzleDb as any)
|
|
||||||
await db.connect()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await db.disconnect()
|
|
||||||
sqlite.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('User Operations', () => {
|
|
||||||
it('should create and retrieve a user', async () => {
|
|
||||||
const newUser = {
|
|
||||||
email: 'test@example.com',
|
|
||||||
firstName: 'Test',
|
|
||||||
lastName: 'User',
|
|
||||||
password: 'password123',
|
|
||||||
role: 'client' as const
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdUser = await db.createUser(newUser)
|
|
||||||
expect(createdUser).toBeDefined()
|
|
||||||
expect(createdUser.id).toBeDefined()
|
|
||||||
expect(createdUser.email).toBe(newUser.email)
|
|
||||||
|
|
||||||
const fetchedUser = await db.getUserById(createdUser.id)
|
|
||||||
expect(fetchedUser).toBeDefined()
|
|
||||||
expect(fetchedUser?.id).toBe(createdUser.id)
|
|
||||||
expect(fetchedUser?.email).toBe(createdUser.email)
|
|
||||||
// SQLite/Drizzle might truncate ms or handle dates differently in test env
|
|
||||||
expect(new Date(fetchedUser!.createdAt).getTime()).toBeLessThanOrEqual(createdUser.createdAt.getTime())
|
|
||||||
expect(new Date(fetchedUser!.updatedAt).getTime()).toBeLessThanOrEqual(createdUser.updatedAt.getTime())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update a user', async () => {
|
|
||||||
const user = await db.createUser({
|
|
||||||
email: 'update@example.com',
|
|
||||||
firstName: 'Update',
|
|
||||||
lastName: 'Me',
|
|
||||||
password: 'password',
|
|
||||||
role: 'client'
|
|
||||||
})
|
|
||||||
|
|
||||||
const updated = await db.updateUser(user.id, { firstName: 'Updated' })
|
|
||||||
expect(updated?.firstName).toBe('Updated')
|
|
||||||
|
|
||||||
const fetched = await db.getUserById(user.id)
|
|
||||||
expect(fetched?.firstName).toBe('Updated')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should delete a user', async () => {
|
|
||||||
const user = await db.createUser({
|
|
||||||
email: 'delete@example.com',
|
|
||||||
firstName: 'Delete',
|
|
||||||
lastName: 'Me',
|
|
||||||
password: 'password',
|
|
||||||
role: 'client'
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleted = await db.deleteUser(user.id)
|
|
||||||
expect(deleted).toBe(true)
|
|
||||||
|
|
||||||
const fetched = await db.getUserById(user.id)
|
|
||||||
expect(fetched).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Client Operations', () => {
|
|
||||||
it('should create and retrieve a client', async () => {
|
|
||||||
const user = await db.createUser({
|
|
||||||
email: 'client@example.com',
|
|
||||||
firstName: 'Client',
|
|
||||||
lastName: 'User',
|
|
||||||
password: 'password',
|
|
||||||
role: 'client'
|
|
||||||
})
|
|
||||||
|
|
||||||
const newClient = {
|
|
||||||
userId: user.id,
|
|
||||||
membershipType: 'basic' as const,
|
|
||||||
membershipStatus: 'active' as const,
|
|
||||||
joinDate: new Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdClient = await db.createClient(newClient)
|
|
||||||
expect(createdClient).toBeDefined()
|
|
||||||
expect(createdClient.userId).toBe(user.id)
|
|
||||||
|
|
||||||
const fetchedClient = await db.getClientById(createdClient.id)
|
|
||||||
expect(fetchedClient).toBeDefined()
|
|
||||||
expect(fetchedClient?.id).toBe(createdClient.id)
|
|
||||||
expect(fetchedClient?.userId).toBe(createdClient.userId)
|
|
||||||
expect(fetchedClient?.membershipType).toBe(createdClient.membershipType)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Fitness Profile Operations', () => {
|
|
||||||
it('should create and retrieve a fitness profile', async () => {
|
|
||||||
const user = await db.createUser({
|
|
||||||
email: 'profile@example.com',
|
|
||||||
firstName: 'Profile',
|
|
||||||
lastName: 'User',
|
|
||||||
password: 'password',
|
|
||||||
role: 'client'
|
|
||||||
})
|
|
||||||
|
|
||||||
const newProfile = {
|
|
||||||
userId: user.id,
|
|
||||||
height: 180,
|
|
||||||
weight: 75,
|
|
||||||
age: 30,
|
|
||||||
gender: 'male' as const,
|
|
||||||
activityLevel: 'moderately_active' as const,
|
|
||||||
fitnessGoals: ['weight_loss'],
|
|
||||||
exerciseHabits: 'None',
|
|
||||||
dietHabits: 'None',
|
|
||||||
medicalConditions: 'None'
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdProfile = await db.createFitnessProfile(newProfile as any)
|
|
||||||
expect(createdProfile).toBeDefined()
|
|
||||||
expect(createdProfile.userId).toBe(user.id)
|
|
||||||
|
|
||||||
const fetchedProfile = await db.getFitnessProfileByUserId(user.id)
|
|
||||||
expect(fetchedProfile).toBeDefined()
|
|
||||||
expect(fetchedProfile?.userId).toBe(user.id)
|
|
||||||
expect(fetchedProfile?.fitnessGoals).toEqual(expect.arrayContaining(['weight_loss']))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Attendance Operations', () => {
|
|
||||||
it('should check in and check out', async () => {
|
|
||||||
const user = await db.createUser({
|
|
||||||
email: 'attendance@example.com',
|
|
||||||
firstName: 'Attendance',
|
|
||||||
lastName: 'User',
|
|
||||||
password: 'password',
|
|
||||||
role: 'client'
|
|
||||||
})
|
|
||||||
|
|
||||||
const checkIn = await db.checkIn(user.id, 'gym')
|
|
||||||
expect(checkIn).toBeDefined()
|
|
||||||
expect(checkIn.checkOutTime).toBeUndefined()
|
|
||||||
|
|
||||||
const activeCheckIn = await db.getActiveCheckIn(user.id)
|
|
||||||
expect(activeCheckIn).toBeDefined()
|
|
||||||
expect(activeCheckIn?.id).toBe(checkIn.id)
|
|
||||||
|
|
||||||
const checkOut = await db.checkOut(checkIn.id)
|
|
||||||
expect(checkOut?.checkOutTime).toBeDefined()
|
|
||||||
|
|
||||||
const activeCheckInAfter = await db.getActiveCheckIn(user.id)
|
|
||||||
expect(activeCheckInAfter).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,555 +0,0 @@
|
|||||||
|
|
||||||
import { IDatabase, User, Client, FitnessProfile, Attendance, Recommendation, FitnessGoal, DatabaseConfig } from './types'
|
|
||||||
import { db as defaultDb, users, clients, fitnessProfiles, attendance, recommendations, fitnessGoals, eq, and, desc, sql } from '@fitai/database'
|
|
||||||
import { InferSelectModel } from 'drizzle-orm'
|
|
||||||
|
|
||||||
export class DrizzleDatabase implements IDatabase {
|
|
||||||
private config: DatabaseConfig
|
|
||||||
private db: typeof defaultDb
|
|
||||||
|
|
||||||
constructor(config: DatabaseConfig, db?: typeof defaultDb) {
|
|
||||||
this.config = config
|
|
||||||
this.db = db || defaultDb
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
|
||||||
// Drizzle with better-sqlite3 connects synchronously on initialization
|
|
||||||
// We can just log here if needed
|
|
||||||
if (this.config.options?.logging) {
|
|
||||||
console.log('Drizzle database connected')
|
|
||||||
}
|
|
||||||
await this.createTables()
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
|
||||||
// better-sqlite3 handle is managed by Drizzle, usually no explicit disconnect needed for connection pooling
|
|
||||||
// but we can close the underlying sqlite instance if we had access to it.
|
|
||||||
// For now, we'll assume it's handled.
|
|
||||||
if (this.config.options?.logging) {
|
|
||||||
console.log('Drizzle database disconnected')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createTables(): Promise<void> {
|
|
||||||
// Users table
|
|
||||||
await this.db.run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
first_name TEXT NOT NULL,
|
|
||||||
last_name TEXT NOT NULL,
|
|
||||||
password TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Clients table
|
|
||||||
await this.db.run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS clients (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
membership_type TEXT NOT NULL CHECK (membership_type IN ('basic', 'premium', 'vip')),
|
|
||||||
membership_status TEXT NOT NULL CHECK (membership_status IN ('active', 'inactive', 'suspended')),
|
|
||||||
join_date INTEGER NOT NULL,
|
|
||||||
last_visit INTEGER,
|
|
||||||
emergency_contact_name TEXT,
|
|
||||||
emergency_contact_phone TEXT,
|
|
||||||
emergency_contact_relationship TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Fitness profiles table
|
|
||||||
await this.db.run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS fitness_profiles (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL UNIQUE,
|
|
||||||
height REAL,
|
|
||||||
weight REAL,
|
|
||||||
age INTEGER,
|
|
||||||
gender TEXT CHECK (gender IN ('male', 'female', 'other', 'prefer_not_to_say')),
|
|
||||||
activity_level TEXT CHECK (activity_level IN ('sedentary', 'lightly_active', 'moderately_active', 'very_active', 'extremely_active')),
|
|
||||||
fitness_goals TEXT,
|
|
||||||
exercise_habits TEXT,
|
|
||||||
diet_habits TEXT,
|
|
||||||
medical_conditions TEXT,
|
|
||||||
allergies TEXT,
|
|
||||||
injuries TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Attendance table
|
|
||||||
await this.db.run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS attendance (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
check_in_time INTEGER NOT NULL,
|
|
||||||
check_out_time INTEGER,
|
|
||||||
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
|
|
||||||
notes TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Recommendations table
|
|
||||||
await this.db.run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS recommendations (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
fitness_profile_id TEXT NOT NULL,
|
|
||||||
recommendation_text TEXT NOT NULL,
|
|
||||||
activity_plan TEXT NOT NULL,
|
|
||||||
diet_plan TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')) DEFAULT 'pending',
|
|
||||||
generated_at INTEGER NOT NULL,
|
|
||||||
approved_at INTEGER,
|
|
||||||
approved_by TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles (id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Fitness Goals table
|
|
||||||
await this.db.run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS fitness_goals (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
fitness_profile_id TEXT,
|
|
||||||
goal_type TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
target_value REAL,
|
|
||||||
current_value REAL,
|
|
||||||
unit TEXT,
|
|
||||||
start_date INTEGER NOT NULL,
|
|
||||||
target_date INTEGER,
|
|
||||||
completed_date INTEGER,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
progress REAL DEFAULT 0,
|
|
||||||
priority TEXT DEFAULT 'medium',
|
|
||||||
notes TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles (id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// User operations
|
|
||||||
async createUser(userData: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string }): Promise<User> {
|
|
||||||
const id = userData.id || Math.random().toString(36).substr(2, 9)
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
const newUser = {
|
|
||||||
...userData,
|
|
||||||
id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.insert(users).values(newUser)
|
|
||||||
return newUser
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserById(id: string): Promise<User | null> {
|
|
||||||
const result = await this.db.select().from(users).where(eq(users.id, id)).get()
|
|
||||||
return result ? this.mapUser(result) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserByEmail(email: string): Promise<User | null> {
|
|
||||||
const result = await this.db.select().from(users).where(eq(users.email, email)).get()
|
|
||||||
return result ? this.mapUser(result) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllUsers(): Promise<User[]> {
|
|
||||||
const results = await this.db.select().from(users).orderBy(desc(users.createdAt)).all()
|
|
||||||
return results.map(this.mapUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUser(id: string, updates: Partial<User>): Promise<User | null> {
|
|
||||||
const { id: _, ...updateData } = updates
|
|
||||||
if (Object.keys(updateData).length === 0) return this.getUserById(id)
|
|
||||||
|
|
||||||
await this.db.update(users)
|
|
||||||
.set({ ...updateData, updatedAt: new Date() })
|
|
||||||
.where(eq(users.id, id))
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return this.getUserById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUser(id: string): Promise<boolean> {
|
|
||||||
const result = await this.db.delete(users).where(eq(users.id, id)).run()
|
|
||||||
return result.changes > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async migrateUserId(oldId: string, newId: string): Promise<void> {
|
|
||||||
await this.db.update(users).set({ id: newId }).where(eq(users.id, oldId)).run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client operations
|
|
||||||
async createClient(clientData: Omit<Client, 'id'>): Promise<Client> {
|
|
||||||
const id = Math.random().toString(36).substr(2, 9)
|
|
||||||
const newClient = {
|
|
||||||
id,
|
|
||||||
...clientData,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.insert(clients).values(newClient as any)
|
|
||||||
return this.mapClient(newClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getClientById(id: string): Promise<Client | null> {
|
|
||||||
const result = await this.db.select().from(clients).where(eq(clients.id, id)).get()
|
|
||||||
return result ? this.mapClient(result) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async getClientByUserId(userId: string): Promise<Client | null> {
|
|
||||||
const result = await this.db.select().from(clients).where(eq(clients.userId, userId)).get()
|
|
||||||
return result ? this.mapClient(result) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllClients(): Promise<Client[]> {
|
|
||||||
const results = await this.db.select().from(clients).orderBy(desc(clients.joinDate)).all()
|
|
||||||
return results.map(this.mapClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateClient(id: string, updates: Partial<Client>): Promise<Client | null> {
|
|
||||||
const { id: _, ...updateData } = updates
|
|
||||||
if (Object.keys(updateData).length === 0) return this.getClientById(id)
|
|
||||||
|
|
||||||
await this.db.update(clients)
|
|
||||||
.set({ ...updateData, updatedAt: new Date() } as any)
|
|
||||||
.where(eq(clients.id, id))
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return this.getClientById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteClient(id: string): Promise<boolean> {
|
|
||||||
const result = await this.db.delete(clients).where(eq(clients.id, id)).run()
|
|
||||||
return result.changes > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fitness Profile operations
|
|
||||||
async createFitnessProfile(profileData: Omit<FitnessProfile, 'id' | 'createdAt' | 'updatedAt'>): Promise<FitnessProfile> {
|
|
||||||
const now = new Date()
|
|
||||||
const id = Math.random().toString(36).substr(2, 9)
|
|
||||||
const newProfile = {
|
|
||||||
id,
|
|
||||||
...profileData,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.insert(fitnessProfiles).values(newProfile as any)
|
|
||||||
return { id, ...profileData, createdAt: now, updatedAt: now }
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFitnessProfileByUserId(userId: string): Promise<FitnessProfile | null> {
|
|
||||||
const result = await this.db.select().from(fitnessProfiles).where(eq(fitnessProfiles.userId, userId)).get()
|
|
||||||
return result ? this.mapFitnessProfile(result) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllFitnessProfiles(): Promise<FitnessProfile[]> {
|
|
||||||
const results = await this.db.select().from(fitnessProfiles).orderBy(desc(fitnessProfiles.createdAt)).all()
|
|
||||||
return results.map(this.mapFitnessProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateFitnessProfile(userId: string, updates: Partial<FitnessProfile>): Promise<FitnessProfile | null> {
|
|
||||||
const { userId: _, ...updateData } = updates
|
|
||||||
if (Object.keys(updateData).length === 0) return this.getFitnessProfileByUserId(userId)
|
|
||||||
|
|
||||||
const dbUpdates = { ...updateData } as any
|
|
||||||
if (updateData.fitnessGoals) {
|
|
||||||
dbUpdates.fitnessGoals = JSON.stringify(updateData.fitnessGoals)
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.update(fitnessProfiles)
|
|
||||||
.set({ ...dbUpdates, updatedAt: new Date() })
|
|
||||||
.where(eq(fitnessProfiles.userId, userId))
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return this.getFitnessProfileByUserId(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteFitnessProfile(userId: string): Promise<boolean> {
|
|
||||||
const result = await this.db.delete(fitnessProfiles).where(eq(fitnessProfiles.userId, userId)).run()
|
|
||||||
return result.changes > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attendance operations
|
|
||||||
async checkIn(userId: string, type: "gym" | "class" | "personal_training", notes?: string): Promise<Attendance> {
|
|
||||||
const id = Math.random().toString(36).substr(2, 9)
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
const newAttendance = {
|
|
||||||
id,
|
|
||||||
userId,
|
|
||||||
checkInTime: now,
|
|
||||||
type,
|
|
||||||
notes,
|
|
||||||
createdAt: now
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.insert(attendance).values(newAttendance as any)
|
|
||||||
|
|
||||||
// Update client last visit
|
|
||||||
const client = await this.getClientByUserId(userId)
|
|
||||||
if (client) {
|
|
||||||
await this.updateClient(client.id, { lastVisit: now })
|
|
||||||
}
|
|
||||||
|
|
||||||
return newAttendance
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkOut(attendanceId: string): Promise<Attendance | null> {
|
|
||||||
const now = new Date()
|
|
||||||
await this.db.update(attendance)
|
|
||||||
.set({ checkOutTime: now })
|
|
||||||
.where(eq(attendance.id, attendanceId))
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return this.getAttendanceById(attendanceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAttendanceById(id: string): Promise<Attendance | null> {
|
|
||||||
const result = await this.db.select().from(attendance).where(eq(attendance.id, id)).get()
|
|
||||||
return result ? this.mapAttendance(result) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAttendanceHistory(userId: string): Promise<Attendance[]> {
|
|
||||||
const results = await this.db.select().from(attendance)
|
|
||||||
.where(eq(attendance.userId, userId))
|
|
||||||
.orderBy(desc(attendance.checkInTime))
|
|
||||||
.all()
|
|
||||||
return results.map(this.mapAttendance)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllAttendance(): Promise<Attendance[]> {
|
|
||||||
const results = await this.db.select().from(attendance)
|
|
||||||
.orderBy(desc(attendance.checkInTime))
|
|
||||||
.all()
|
|
||||||
return results.map(this.mapAttendance)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getActiveCheckIn(userId: string): Promise<Attendance | null> {
|
|
||||||
// Drizzle doesn't support IS NULL in where directly with simple syntax sometimes, but eq(col, null) works or isNull(col)
|
|
||||||
// We need to check how to filter for null checkOutTime.
|
|
||||||
// In Drizzle, we can filter in JS or use isNull operator if imported.
|
|
||||||
// Let's fetch recent and filter for now to be safe, or use raw sql if needed, but better to use Drizzle operators.
|
|
||||||
// Actually, we can just fetch all for user and filter in memory since it's unlikely to be huge for active checkins,
|
|
||||||
// but correct way is `isNull(attendance.checkOutTime)`.
|
|
||||||
// Since I didn't import `isNull`, I'll fetch recent history and find first active.
|
|
||||||
|
|
||||||
const history = await this.getAttendanceHistory(userId)
|
|
||||||
return history.find(a => !a.checkOutTime) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recommendation operations
|
|
||||||
async createRecommendation(data: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">): Promise<Recommendation> {
|
|
||||||
const now = new Date()
|
|
||||||
const newRec = {
|
|
||||||
...data,
|
|
||||||
createdAt: now,
|
|
||||||
status: data.status || 'pending'
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.insert(recommendations).values(newRec as any)
|
|
||||||
return newRec as Recommendation
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
|
|
||||||
const results = await this.db.select().from(recommendations)
|
|
||||||
.where(eq(recommendations.userId, userId))
|
|
||||||
.orderBy(desc(recommendations.createdAt))
|
|
||||||
.all()
|
|
||||||
return results.map(this.mapRecommendation)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllRecommendations(): Promise<Recommendation[]> {
|
|
||||||
const results = await this.db.select().from(recommendations)
|
|
||||||
.orderBy(desc(recommendations.createdAt))
|
|
||||||
.all()
|
|
||||||
return results.map(this.mapRecommendation)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
|
|
||||||
const { id: _, ...updateData } = updates
|
|
||||||
if (Object.keys(updateData).length === 0) return this.getRecommendationById(id)
|
|
||||||
|
|
||||||
await this.db.update(recommendations)
|
|
||||||
.set(updateData as any)
|
|
||||||
.where(eq(recommendations.id, id))
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return this.getRecommendationById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteRecommendation(id: string): Promise<boolean> {
|
|
||||||
const result = await this.db.delete(recommendations).where(eq(recommendations.id, id)).run()
|
|
||||||
return result.changes > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRecommendationById(id: string): Promise<Recommendation | null> {
|
|
||||||
const result = await this.db.select().from(recommendations).where(eq(recommendations.id, id)).get()
|
|
||||||
return result ? this.mapRecommendation(result) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fitness Goals operations
|
|
||||||
async createFitnessGoal(goalData: Omit<FitnessGoal, "createdAt" | "updatedAt">): Promise<FitnessGoal> {
|
|
||||||
const now = new Date()
|
|
||||||
const newGoal = {
|
|
||||||
...goalData,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.insert(fitnessGoals).values(newGoal as any)
|
|
||||||
return newGoal as FitnessGoal
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFitnessGoalById(id: string): Promise<FitnessGoal | null> {
|
|
||||||
const result = await this.db.select().from(fitnessGoals).where(eq(fitnessGoals.id, id)).get()
|
|
||||||
return result ? this.mapFitnessGoal(result) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFitnessGoalsByUserId(userId: string, status?: string): Promise<FitnessGoal[]> {
|
|
||||||
let query = this.db.select().from(fitnessGoals).where(eq(fitnessGoals.userId, userId))
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
query = this.db.select().from(fitnessGoals).where(and(eq(fitnessGoals.userId, userId), eq(fitnessGoals.status, status as any)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await query.orderBy(desc(fitnessGoals.createdAt)).all()
|
|
||||||
return results.map(this.mapFitnessGoal)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateFitnessGoal(id: string, updates: Partial<FitnessGoal>): Promise<FitnessGoal | null> {
|
|
||||||
const { id: _, ...updateData } = updates
|
|
||||||
if (Object.keys(updateData).length === 0) return this.getFitnessGoalById(id)
|
|
||||||
|
|
||||||
await this.db.update(fitnessGoals)
|
|
||||||
.set({ ...updateData, updatedAt: new Date() } as any)
|
|
||||||
.where(eq(fitnessGoals.id, id))
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return this.getFitnessGoalById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteFitnessGoal(id: string): Promise<boolean> {
|
|
||||||
const result = await this.db.delete(fitnessGoals).where(eq(fitnessGoals.id, id)).run()
|
|
||||||
return result.changes > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateGoalProgress(id: string, currentValue: number): Promise<FitnessGoal | null> {
|
|
||||||
const goal = await this.getFitnessGoalById(id)
|
|
||||||
if (!goal) return null
|
|
||||||
|
|
||||||
let progress = goal.progress
|
|
||||||
if (goal.targetValue && goal.targetValue > 0) {
|
|
||||||
progress = Math.min(100, (currentValue / goal.targetValue) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.update(fitnessGoals)
|
|
||||||
.set({ currentValue, progress, updatedAt: new Date() })
|
|
||||||
.where(eq(fitnessGoals.id, id))
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return this.getFitnessGoalById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeGoal(id: string): Promise<FitnessGoal | null> {
|
|
||||||
const now = new Date()
|
|
||||||
await this.db.update(fitnessGoals)
|
|
||||||
.set({ status: 'completed', progress: 100, completedDate: now, updatedAt: now })
|
|
||||||
.where(eq(fitnessGoals.id, id))
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return this.getFitnessGoalById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDashboardStats(): Promise<{ totalUsers: number; activeClients: number; totalRevenue: number; revenueGrowth: number }> {
|
|
||||||
// Placeholder implementation as per original sqlite.ts (which didn't implement this either in the viewed snippet,
|
|
||||||
// but interface requires it. I'll provide a basic implementation or mock).
|
|
||||||
// The original sqlite.ts snippet ended before showing this method, but the interface has it.
|
|
||||||
// I'll implement a basic count.
|
|
||||||
|
|
||||||
const allUsers = await this.db.select().from(users).all()
|
|
||||||
const activeClients = await this.db.select().from(clients).where(eq(clients.membershipStatus, 'active')).all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalUsers: allUsers.length,
|
|
||||||
activeClients: activeClients.length,
|
|
||||||
totalRevenue: 0, // Not tracking payments yet
|
|
||||||
revenueGrowth: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mappers
|
|
||||||
private mapUser(row: any): User {
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
createdAt: new Date(row.createdAt),
|
|
||||||
updatedAt: new Date(row.updatedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapClient(row: any): Client {
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
joinDate: new Date(row.joinDate),
|
|
||||||
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapFitnessProfile(row: any): FitnessProfile {
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
createdAt: new Date(row.createdAt),
|
|
||||||
updatedAt: new Date(row.updatedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapAttendance(row: any): Attendance {
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
checkInTime: new Date(row.checkInTime),
|
|
||||||
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
|
||||||
createdAt: new Date(row.createdAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapRecommendation(row: any): Recommendation {
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
createdAt: new Date(row.createdAt),
|
|
||||||
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapFitnessGoal(row: any): FitnessGoal {
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
startDate: new Date(row.startDate),
|
|
||||||
targetDate: row.targetDate ? new Date(row.targetDate) : undefined,
|
|
||||||
completedDate: row.completedDate ? new Date(row.completedDate) : undefined,
|
|
||||||
createdAt: new Date(row.createdAt),
|
|
||||||
updatedAt: new Date(row.updatedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IDatabase, DatabaseConfig } from './types'
|
import { IDatabase, DatabaseConfig } from './types'
|
||||||
import { DrizzleDatabase } from './drizzle'
|
import { SQLiteDatabase } from './sqlite'
|
||||||
|
|
||||||
// Database factory - creates appropriate database instance based on config
|
// Database factory - creates appropriate database instance based on config
|
||||||
export class DatabaseFactory {
|
export class DatabaseFactory {
|
||||||
@@ -20,28 +20,28 @@ export class DatabaseFactory {
|
|||||||
// Create new database instance based on type
|
// Create new database instance based on type
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case 'sqlite':
|
case 'sqlite':
|
||||||
this.instance = new DrizzleDatabase(config)
|
this.instance = new SQLiteDatabase(config)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'postgresql':
|
case 'postgresql':
|
||||||
// TODO: Implement PostgreSQLDatabase
|
// TODO: Implement PostgreSQLDatabase
|
||||||
throw new Error('PostgreSQL implementation not yet available')
|
throw new Error('PostgreSQL implementation not yet available')
|
||||||
|
|
||||||
case 'mysql':
|
case 'mysql':
|
||||||
// TODO: Implement MySQLDatabase
|
// TODO: Implement MySQLDatabase
|
||||||
throw new Error('MySQL implementation not yet available')
|
throw new Error('MySQL implementation not yet available')
|
||||||
|
|
||||||
case 'mongodb':
|
case 'mongodb':
|
||||||
// TODO: Implement MongoDBDatabase
|
// TODO: Implement MongoDBDatabase
|
||||||
throw new Error('MongoDB implementation not yet available')
|
throw new Error('MongoDB implementation not yet available')
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported database type: ${config.type}`)
|
throw new Error(`Unsupported database type: ${config.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.instance.connect()
|
await this.instance.connect()
|
||||||
this.config = config
|
this.config = config
|
||||||
|
|
||||||
return this.instance
|
return this.instance
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export class DatabaseFactory {
|
|||||||
|
|
||||||
private static isSameConfig(config: DatabaseConfig): boolean {
|
private static isSameConfig(config: DatabaseConfig): boolean {
|
||||||
if (!this.config) return false
|
if (!this.config) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.config.type === config.type &&
|
this.config.type === config.type &&
|
||||||
JSON.stringify(this.config.connection) === JSON.stringify(config.connection)
|
JSON.stringify(this.config.connection) === JSON.stringify(config.connection)
|
||||||
|
|||||||
@@ -0,0 +1,868 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
import { IDatabase, User, Client, FitnessProfile, Attendance, Recommendation, DatabaseConfig } from './types'
|
||||||
|
|
||||||
|
export class SQLiteDatabase implements IDatabase {
|
||||||
|
private db: Database.Database | null = null
|
||||||
|
private config: DatabaseConfig
|
||||||
|
|
||||||
|
constructor(config: DatabaseConfig) {
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dbPath = this.config.connection.filename || path.join(process.cwd(), 'data', 'fitai.db')
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dataDir = path.dirname(dbPath)
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db = new Database(dbPath)
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
this.db.exec('PRAGMA foreign_keys = ON')
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
await this.createTables()
|
||||||
|
|
||||||
|
if (this.config.options?.logging) {
|
||||||
|
console.log('SQLite database connected successfully at:', dbPath)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to SQLite database:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.db) {
|
||||||
|
this.db.close()
|
||||||
|
this.db = null
|
||||||
|
if (this.config.options?.logging) {
|
||||||
|
console.log('SQLite database disconnected')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createTables(): Promise<void> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
// Users table
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
firstName TEXT NOT NULL,
|
||||||
|
lastName TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
updatedAt DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Clients table
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
membershipType TEXT NOT NULL CHECK (membershipType IN ('basic', 'premium', 'vip')),
|
||||||
|
membershipStatus TEXT NOT NULL CHECK (membershipStatus IN ('active', 'inactive', 'expired')),
|
||||||
|
joinDate DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Fitness profiles table
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS fitness_profiles (
|
||||||
|
userId TEXT PRIMARY KEY,
|
||||||
|
height TEXT NOT NULL,
|
||||||
|
weight TEXT NOT NULL,
|
||||||
|
age TEXT NOT NULL,
|
||||||
|
gender TEXT NOT NULL CHECK (gender IN ('male', 'female', 'other')),
|
||||||
|
activityLevel TEXT NOT NULL CHECK (activityLevel IN ('sedentary', 'light', 'moderate', 'active', 'very_active')),
|
||||||
|
fitnessGoals TEXT NOT NULL, -- JSON array
|
||||||
|
exerciseHabits TEXT,
|
||||||
|
dietHabits TEXT,
|
||||||
|
medicalConditions TEXT,
|
||||||
|
allergies TEXT,
|
||||||
|
injuries TEXT,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
updatedAt DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Create indexes for better performance
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clients_userId ON clients(userId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Attendance table migration: change from clientId to userId
|
||||||
|
// Check if old table exists and migrate
|
||||||
|
const tableInfo = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'").get() as any;
|
||||||
|
|
||||||
|
if (tableInfo) {
|
||||||
|
// Check if table has clientId column (old schema)
|
||||||
|
const columns = this.db.prepare("PRAGMA table_info(attendance)").all() as any[];
|
||||||
|
const hasClientId = columns.some((col: any) => col.name === 'clientId');
|
||||||
|
|
||||||
|
if (hasClientId) {
|
||||||
|
console.log('Migrating attendance table from clientId to userId...');
|
||||||
|
|
||||||
|
// Create new table with userId
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS attendance_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
checkInTime DATETIME NOT NULL,
|
||||||
|
checkOutTime DATETIME,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
|
||||||
|
notes TEXT,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migrate data: map clientId to userId via clients table
|
||||||
|
this.db.exec(`
|
||||||
|
INSERT INTO attendance_new (id, userId, checkInTime, checkOutTime, type, notes, createdAt)
|
||||||
|
SELECT a.id, c.userId, a.checkInTime, a.checkOutTime, a.type, a.notes, a.createdAt
|
||||||
|
FROM attendance a
|
||||||
|
JOIN clients c ON a.clientId = c.id
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop old table and rename new one
|
||||||
|
this.db.exec(`DROP TABLE attendance`);
|
||||||
|
this.db.exec(`ALTER TABLE attendance_new RENAME TO attendance`);
|
||||||
|
|
||||||
|
console.log('Attendance table migration completed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new attendance table with userId
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS attendance (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
checkInTime DATETIME NOT NULL,
|
||||||
|
checkOutTime DATETIME,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
|
||||||
|
notes TEXT,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendations table
|
||||||
|
// Removed DROP TABLE to persist data. Schema is now stable.
|
||||||
|
// this.db.exec(`DROP TABLE IF EXISTS recommendations`)
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recommendations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
fitnessProfileId TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
activityPlan TEXT,
|
||||||
|
dietPlan TEXT,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
approvedAt DATETIME,
|
||||||
|
approvedBy TEXT,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attendance_userId ON attendance(userId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User operations
|
||||||
|
async createUser(
|
||||||
|
userData: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
|
||||||
|
): Promise<User> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const id = userData.id || Math.random().toString(36).substr(2, 9)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const user: User = {
|
||||||
|
...userData,
|
||||||
|
id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO users(id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
user.id, user.email, user.firstName, user.lastName, user.password,
|
||||||
|
user.phone, user.role, user.createdAt.toISOString(), user.updatedAt.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserById(id: string): Promise<User | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM users WHERE id = ?')
|
||||||
|
const row = stmt.get(id)
|
||||||
|
|
||||||
|
return row ? this.mapRowToUser(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM users WHERE email = ?')
|
||||||
|
const row = stmt.get(email)
|
||||||
|
|
||||||
|
return row ? this.mapRowToUser(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllUsers(): Promise<User[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM users ORDER BY createdAt DESC')
|
||||||
|
const rows = stmt.all()
|
||||||
|
|
||||||
|
return rows.map(row => this.mapRowToUser(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(id: string, updates: Partial<User>): Promise<User | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id')
|
||||||
|
if (fields.length === 0) return this.getUserById(id)
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||||
|
const values = fields.map(field => (updates as any)[field])
|
||||||
|
values.push(new Date().toISOString()) // updatedAt
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ? `)
|
||||||
|
stmt.run(values)
|
||||||
|
|
||||||
|
return this.getUserById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(id: string): Promise<boolean> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('DELETE FROM users WHERE id = ?')
|
||||||
|
const result = stmt.run(id)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateUserId(oldId: string, newId: string): Promise<void> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
// We need to disable foreign keys temporarily if we want to update ID without cascade (if cascade isn't set)
|
||||||
|
// But we should try to update and let cascade handle it if possible.
|
||||||
|
// Since we didn't set ON UPDATE CASCADE, we might need to manually update references or use PRAGMA.
|
||||||
|
|
||||||
|
// Simplest way: Update the ID. If it fails due to FK, we have to handle it.
|
||||||
|
// For the Super Admin seed case, there are no dependencies.
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('UPDATE users SET id = ? WHERE id = ?')
|
||||||
|
stmt.run(newId, oldId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client operations
|
||||||
|
async createClient(clientData: Omit<Client, 'id'>): Promise<Client> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const id = Math.random().toString(36).substr(2, 9)
|
||||||
|
const client: Client = { id, ...clientData }
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO clients(id, userId, membershipType, membershipStatus, joinDate)
|
||||||
|
VALUES(?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
client.id, client.userId, client.membershipType,
|
||||||
|
client.membershipStatus, client.joinDate.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClientById(id: string): Promise<Client | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM clients WHERE id = ?')
|
||||||
|
const row = stmt.get(id)
|
||||||
|
return row ? this.mapRowToClient(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClientByUserId(userId: string): Promise<Client | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM clients WHERE userId = ?')
|
||||||
|
const row = stmt.get(userId)
|
||||||
|
return row ? this.mapRowToClient(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllClients(): Promise<Client[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM clients ORDER BY joinDate DESC')
|
||||||
|
const rows = stmt.all()
|
||||||
|
return rows.map(row => this.mapRowToClient(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateClient(id: string, updates: Partial<Client>): Promise<Client | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id')
|
||||||
|
if (fields.length === 0) return this.getClientById(id)
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||||
|
const values = fields.map(field => (updates as any)[field])
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ? `)
|
||||||
|
stmt.run(values)
|
||||||
|
|
||||||
|
return this.getClientById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteClient(id: string): Promise<boolean> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('DELETE FROM clients WHERE id = ?')
|
||||||
|
const result = stmt.run(id)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fitness Profile operations
|
||||||
|
async createFitnessProfile(profileData: Omit<FitnessProfile, 'createdAt' | 'updatedAt'>): Promise<FitnessProfile> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const profile: FitnessProfile = {
|
||||||
|
...profileData,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO fitness_profiles
|
||||||
|
(userId, height, weight, age, gender, activityLevel, fitnessGoals,
|
||||||
|
exerciseHabits, dietHabits, medicalConditions, allergies, injuries, createdAt, updatedAt)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
profile.userId, profile.height, profile.weight, profile.age, profile.gender,
|
||||||
|
profile.activityLevel, JSON.stringify(profile.fitnessGoals), profile.exerciseHabits,
|
||||||
|
profile.dietHabits, profile.medicalConditions, profile.allergies, profile.injuries,
|
||||||
|
profile.createdAt.toISOString(), profile.updatedAt.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFitnessProfileByUserId(userId: string): Promise<FitnessProfile | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM fitness_profiles WHERE userId = ?')
|
||||||
|
const row = stmt.get(userId)
|
||||||
|
|
||||||
|
return row ? this.mapRowToFitnessProfile(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFitnessProfiles(): Promise<FitnessProfile[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM fitness_profiles ORDER BY createdAt DESC')
|
||||||
|
const rows = stmt.all()
|
||||||
|
return rows.map(row => this.mapRowToFitnessProfile(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFitnessProfile(userId: string, updates: Partial<FitnessProfile>): Promise<FitnessProfile | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'userId' && key !== 'createdAt')
|
||||||
|
if (fields.length === 0) return this.getFitnessProfileByUserId(userId)
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||||
|
const values = fields.map(field => {
|
||||||
|
const value = (updates as any)[field]
|
||||||
|
return field === 'fitnessGoals' ? JSON.stringify(value) : value
|
||||||
|
})
|
||||||
|
values.push(new Date().toISOString()) // updatedAt
|
||||||
|
values.push(userId)
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ? `)
|
||||||
|
stmt.run(values)
|
||||||
|
|
||||||
|
return this.getFitnessProfileByUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFitnessProfile(userId: string): Promise<boolean> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('DELETE FROM fitness_profiles WHERE userId = ?')
|
||||||
|
const result = stmt.run(userId)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendance operations
|
||||||
|
async checkIn(userId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const id = Math.random().toString(36).substr(2, 9)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const attendance: Attendance = {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
checkInTime: now,
|
||||||
|
type,
|
||||||
|
notes,
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO attendance(id, userId, checkInTime, type, notes, createdAt)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
attendance.id, attendance.userId, attendance.checkInTime.toISOString(),
|
||||||
|
attendance.type, attendance.notes, attendance.createdAt.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update client last visit if user is a client
|
||||||
|
const client = await this.getClientByUserId(userId);
|
||||||
|
if (client) {
|
||||||
|
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
|
||||||
|
now.toISOString(),
|
||||||
|
client.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return attendance
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkOut(attendanceId: string): Promise<Attendance | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const stmt = this.db.prepare('UPDATE attendance SET checkOutTime = ? WHERE id = ?')
|
||||||
|
stmt.run(now.toISOString(), attendanceId)
|
||||||
|
|
||||||
|
return this.getAttendanceById(attendanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttendanceById(id: string): Promise<Attendance | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM attendance WHERE id = ?')
|
||||||
|
const row = stmt.get(id)
|
||||||
|
return row ? this.mapRowToAttendance(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttendanceHistory(userId: string): Promise<Attendance[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM attendance WHERE userId = ? ORDER BY checkInTime DESC')
|
||||||
|
const rows = stmt.all(userId)
|
||||||
|
return rows.map(row => this.mapRowToAttendance(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAttendance(): Promise<Attendance[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM attendance ORDER BY checkInTime DESC')
|
||||||
|
const rows = stmt.all()
|
||||||
|
return rows.map(row => this.mapRowToAttendance(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveCheckIn(userId: string): Promise<Attendance | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM attendance WHERE userId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
|
||||||
|
const row = stmt.get(userId)
|
||||||
|
return row ? this.mapRowToAttendance(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods to map database rows to entities
|
||||||
|
private mapRowToUser(row: any): User {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
firstName: row.firstName,
|
||||||
|
lastName: row.lastName,
|
||||||
|
password: row.password,
|
||||||
|
phone: row.phone,
|
||||||
|
role: row.role,
|
||||||
|
createdAt: new Date(row.createdAt),
|
||||||
|
updatedAt: new Date(row.updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRowToClient(row: any): Client {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
membershipType: row.membershipType,
|
||||||
|
membershipStatus: row.membershipStatus,
|
||||||
|
joinDate: new Date(row.joinDate),
|
||||||
|
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRowToFitnessProfile(row: any): FitnessProfile {
|
||||||
|
return {
|
||||||
|
userId: row.userId,
|
||||||
|
height: row.height,
|
||||||
|
weight: row.weight,
|
||||||
|
age: row.age,
|
||||||
|
gender: row.gender,
|
||||||
|
activityLevel: row.activityLevel,
|
||||||
|
fitnessGoals: JSON.parse(row.fitnessGoals || '[]'),
|
||||||
|
exerciseHabits: row.exerciseHabits,
|
||||||
|
dietHabits: row.dietHabits,
|
||||||
|
medicalConditions: row.medicalConditions,
|
||||||
|
allergies: row.allergies,
|
||||||
|
injuries: row.injuries,
|
||||||
|
createdAt: new Date(row.createdAt),
|
||||||
|
updatedAt: new Date(row.updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRowToAttendance(row: any): Attendance {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
checkInTime: new Date(row.checkInTime),
|
||||||
|
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||||
|
type: row.type,
|
||||||
|
notes: row.notes,
|
||||||
|
createdAt: new Date(row.createdAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation operations
|
||||||
|
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'approvedAt' | 'approvedBy'>): Promise<Recommendation> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const recommendation: Recommendation = {
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
status: data.status || 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO recommendations (
|
||||||
|
id, userId, fitnessProfileId, type, content,
|
||||||
|
activityPlan, dietPlan, status, createdAt
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
recommendation.id, recommendation.userId, recommendation.fitnessProfileId,
|
||||||
|
recommendation.type, recommendation.content, recommendation.activityPlan,
|
||||||
|
recommendation.dietPlan, recommendation.status,
|
||||||
|
recommendation.createdAt.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
return recommendation
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE userId = ? ORDER BY createdAt DESC')
|
||||||
|
const rows = stmt.all(userId)
|
||||||
|
|
||||||
|
return rows.map(row => this.mapRowToRecommendation(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllRecommendations(): Promise<Recommendation[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM recommendations ORDER BY createdAt DESC')
|
||||||
|
const rows = stmt.all()
|
||||||
|
|
||||||
|
return rows.map(row => this.mapRowToRecommendation(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'userId')
|
||||||
|
if (fields.length === 0) {
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
|
||||||
|
const row = stmt.get(id)
|
||||||
|
return row ? this.mapRowToRecommendation(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||||
|
const values = fields.map(field => {
|
||||||
|
const val = (updates as any)[field]
|
||||||
|
return val instanceof Date ? val.toISOString() : val
|
||||||
|
})
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause} WHERE id = ?`)
|
||||||
|
stmt.run(values)
|
||||||
|
|
||||||
|
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
|
||||||
|
const row = getStmt.get(id)
|
||||||
|
return row ? this.mapRowToRecommendation(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRecommendation(id: string): Promise<boolean> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('DELETE FROM recommendations WHERE id = ?')
|
||||||
|
const result = stmt.run(id)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRowToRecommendation(row: any): Recommendation {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
fitnessProfileId: row.fitnessProfileId,
|
||||||
|
type: row.type,
|
||||||
|
content: row.content,
|
||||||
|
activityPlan: row.activityPlan,
|
||||||
|
dietPlan: row.dietPlan,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: new Date(row.createdAt),
|
||||||
|
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
|
||||||
|
approvedBy: row.approvedBy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fitness Goals operations
|
||||||
|
async createFitnessGoal(goalData: Omit<import('./types').FitnessGoal, 'createdAt' | 'updatedAt'>): Promise<import('./types').FitnessGoal> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const goal: import('./types').FitnessGoal = {
|
||||||
|
...goalData,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO fitness_goals (
|
||||||
|
id, user_id, fitness_profile_id, goal_type, title, description,
|
||||||
|
target_value, current_value, unit, start_date, target_date,
|
||||||
|
completed_date, status, progress, priority, notes, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
goal.id,
|
||||||
|
goal.userId,
|
||||||
|
goal.fitnessProfileId || null,
|
||||||
|
goal.goalType,
|
||||||
|
goal.title,
|
||||||
|
goal.description || null,
|
||||||
|
goal.targetValue || null,
|
||||||
|
goal.currentValue || null,
|
||||||
|
goal.unit || null,
|
||||||
|
goal.startDate.toISOString(),
|
||||||
|
goal.targetDate ? goal.targetDate.toISOString() : null,
|
||||||
|
goal.completedDate ? goal.completedDate.toISOString() : null,
|
||||||
|
goal.status,
|
||||||
|
goal.progress,
|
||||||
|
goal.priority,
|
||||||
|
goal.notes || null,
|
||||||
|
goal.createdAt.toISOString(),
|
||||||
|
goal.updatedAt.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFitnessGoalById(id: string): Promise<import('./types').FitnessGoal | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM fitness_goals WHERE id = ?')
|
||||||
|
const row = stmt.get(id)
|
||||||
|
|
||||||
|
return row ? this.mapRowToFitnessGoal(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFitnessGoalsByUserId(userId: string, status?: string): Promise<import('./types').FitnessGoal[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM fitness_goals WHERE user_id = ?'
|
||||||
|
const params: any[] = [userId]
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query += ' AND status = ?'
|
||||||
|
params.push(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC'
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(query)
|
||||||
|
const rows = stmt.all(...params)
|
||||||
|
|
||||||
|
return rows.map(row => this.mapRowToFitnessGoal(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFitnessGoal(id: string, updates: Partial<import('./types').FitnessGoal>): Promise<import('./types').FitnessGoal | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'createdAt')
|
||||||
|
if (fields.length === 0) return this.getFitnessGoalById(id)
|
||||||
|
|
||||||
|
// Map camelCase to snake_case for database columns
|
||||||
|
const columnMap: Record<string, string> = {
|
||||||
|
userId: 'user_id',
|
||||||
|
fitnessProfileId: 'fitness_profile_id',
|
||||||
|
goalType: 'goal_type',
|
||||||
|
targetValue: 'target_value',
|
||||||
|
currentValue: 'current_value',
|
||||||
|
startDate: 'start_date',
|
||||||
|
targetDate: 'target_date',
|
||||||
|
completedDate: 'completed_date',
|
||||||
|
updatedAt: 'updated_at'
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${columnMap[field] || field} = ?`).join(', ')
|
||||||
|
const values = fields.map(field => {
|
||||||
|
const val = (updates as any)[field]
|
||||||
|
return val instanceof Date ? val.toISOString() : val
|
||||||
|
})
|
||||||
|
values.push(new Date().toISOString()) // updatedAt
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`UPDATE fitness_goals SET ${setClause}, updated_at = ? WHERE id = ?`)
|
||||||
|
stmt.run(values)
|
||||||
|
|
||||||
|
return this.getFitnessGoalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFitnessGoal(id: string): Promise<boolean> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('DELETE FROM fitness_goals WHERE id = ?')
|
||||||
|
const result = stmt.run(id)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateGoalProgress(id: string, currentValue: number): Promise<import('./types').FitnessGoal | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
// Get the goal to calculate progress
|
||||||
|
const goal = await this.getFitnessGoalById(id)
|
||||||
|
if (!goal) return null
|
||||||
|
|
||||||
|
let progress = goal.progress
|
||||||
|
if (goal.targetValue && goal.targetValue > 0) {
|
||||||
|
progress = Math.min(100, (currentValue / goal.targetValue) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
'UPDATE fitness_goals SET current_value = ?, progress = ?, updated_at = ? WHERE id = ?'
|
||||||
|
)
|
||||||
|
stmt.run(currentValue, progress, new Date().toISOString(), id)
|
||||||
|
|
||||||
|
return this.getFitnessGoalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeGoal(id: string): Promise<import('./types').FitnessGoal | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
'UPDATE fitness_goals SET status = ?, progress = ?, completed_date = ?, updated_at = ? WHERE id = ?'
|
||||||
|
)
|
||||||
|
stmt.run('completed', 100, now.toISOString(), now.toISOString(), id)
|
||||||
|
|
||||||
|
return this.getFitnessGoalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRowToFitnessGoal(row: any): import('./types').FitnessGoal {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
fitnessProfileId: row.fitness_profile_id,
|
||||||
|
goalType: row.goal_type,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
targetValue: row.target_value,
|
||||||
|
currentValue: row.current_value,
|
||||||
|
unit: row.unit,
|
||||||
|
startDate: new Date(row.start_date),
|
||||||
|
targetDate: row.target_date ? new Date(row.target_date) : undefined,
|
||||||
|
completedDate: row.completed_date ? new Date(row.completed_date) : undefined,
|
||||||
|
status: row.status,
|
||||||
|
progress: row.progress,
|
||||||
|
priority: row.priority,
|
||||||
|
notes: row.notes,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: new Date(row.updated_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDashboardStats(): Promise<{
|
||||||
|
totalUsers: number;
|
||||||
|
activeClients: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
revenueGrowth: number;
|
||||||
|
}> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
// Total Users
|
||||||
|
const userCountStmt = this.db.prepare('SELECT COUNT(*) as count FROM users')
|
||||||
|
const userCount = (userCountStmt.get() as any).count
|
||||||
|
|
||||||
|
// Active Clients
|
||||||
|
const activeClientCountStmt = this.db.prepare("SELECT COUNT(*) as count FROM clients WHERE membershipStatus = 'active'")
|
||||||
|
const activeClientCount = (activeClientCountStmt.get() as any).count
|
||||||
|
|
||||||
|
// Total Revenue (assuming payments table exists, handling if it's empty)
|
||||||
|
// Note: We need to create the payments table first if it doesn't exist in createTables
|
||||||
|
// For now, returning 0 if table doesn't exist or is empty
|
||||||
|
let totalRevenue = 0
|
||||||
|
let revenueGrowth = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const revenueStmt = this.db.prepare('SELECT SUM(amount) as total FROM payments WHERE status = "completed"')
|
||||||
|
const revenueResult = revenueStmt.get() as any
|
||||||
|
totalRevenue = revenueResult?.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
// Table might not exist yet
|
||||||
|
console.warn('Payments table query failed, returning 0 revenue')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: userCount,
|
||||||
|
activeClients: activeClientCount,
|
||||||
|
totalRevenue,
|
||||||
|
revenueGrowth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,86 @@
|
|||||||
import { User as SharedUser, Client, FitnessProfile, Attendance, Recommendation, FitnessGoal } from "@fitai/shared";
|
|
||||||
|
|
||||||
// Database Entity Types
|
// Database Entity Types
|
||||||
export interface User extends SharedUser {
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
phone?: string;
|
||||||
|
role: "superAdmin" | "admin" | "trainer" | "client";
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal };
|
export interface Client {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
membershipType: "basic" | "premium" | "vip";
|
||||||
|
membershipStatus: "active" | "inactive" | "expired";
|
||||||
|
joinDate: Date;
|
||||||
|
lastVisit?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitnessProfile {
|
||||||
|
userId: string;
|
||||||
|
height: string;
|
||||||
|
weight: string;
|
||||||
|
age: string;
|
||||||
|
gender: "male" | "female" | "other";
|
||||||
|
activityLevel: "sedentary" | "light" | "moderate" | "active" | "very_active";
|
||||||
|
fitnessGoals: string[];
|
||||||
|
exerciseHabits: string;
|
||||||
|
dietHabits: string;
|
||||||
|
medicalConditions: string;
|
||||||
|
allergies?: string;
|
||||||
|
injuries?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attendance {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
checkInTime: Date;
|
||||||
|
checkOutTime?: Date;
|
||||||
|
type: "gym" | "class" | "personal_training";
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
fitnessProfileId?: string;
|
||||||
|
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
||||||
|
content: string;
|
||||||
|
activityPlan?: string;
|
||||||
|
dietPlan?: string;
|
||||||
|
status: "pending" | "approved" | "rejected" | "completed";
|
||||||
|
createdAt: Date;
|
||||||
|
approvedAt?: Date;
|
||||||
|
approvedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitnessGoal {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
fitnessProfileId?: string;
|
||||||
|
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom";
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
currentValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
startDate: Date;
|
||||||
|
targetDate?: Date;
|
||||||
|
completedDate?: Date;
|
||||||
|
status: "active" | "completed" | "abandoned" | "paused";
|
||||||
|
progress: number;
|
||||||
|
priority: "low" | "medium" | "high";
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
// Database Interface - allows us to swap implementations
|
// Database Interface - allows us to swap implementations
|
||||||
export interface IDatabase {
|
export interface IDatabase {
|
||||||
|
|||||||
@@ -1 +1,14 @@
|
|||||||
export { cn, formatDate } from "@fitai/shared"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date) {
|
||||||
|
return new Date(date).toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
|
|||||||
Generated
+147
-256
@@ -8,7 +8,7 @@
|
|||||||
"name": "@fitai/mobile",
|
"name": "@fitai/mobile",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/clerk-expo": "^2.19.11",
|
"@clerk/clerk-expo": "^2.18.3",
|
||||||
"@expo/vector-icons": "^15.0.0",
|
"@expo/vector-icons": "^15.0.0",
|
||||||
"@hookform/resolvers": "^3.3.0",
|
"@hookform/resolvers": "^3.3.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
@@ -19,22 +19,23 @@
|
|||||||
"ajv-keywords": "^5.1.0",
|
"ajv-keywords": "^5.1.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"expo": "~54.0.23",
|
"expo": "~54.0.23",
|
||||||
"expo-auth-session": "^7.0.10",
|
"expo-auth-session": "^7.0.8",
|
||||||
"expo-barcode-scanner": "^13.0.1",
|
"expo-barcode-scanner": "^13.0.1",
|
||||||
"expo-camera": "~17.0.9",
|
"expo-camera": "~17.0.9",
|
||||||
"expo-constants": "^18.0.10",
|
"expo-constants": "^18.0.10",
|
||||||
"expo-crypto": "^15.0.8",
|
"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",
|
||||||
"expo-status-bar": "^3.0.8",
|
"expo-status-bar": "^3.0.8",
|
||||||
"expo-web-browser": "^15.0.10",
|
"expo-web-browser": "^15.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
@@ -1563,15 +1564,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/clerk-expo": {
|
"node_modules/@clerk/clerk-expo": {
|
||||||
"version": "2.19.11",
|
"version": "2.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-expo/-/clerk-expo-2.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/clerk-expo/-/clerk-expo-2.18.3.tgz",
|
||||||
"integrity": "sha512-zwCPDJm/WKe91rMGPequVKgGGWmUR/2OIR0IBqXg0hLaUFjEdce0hoB0QSVdc8TweC8djy+5vKVZIfQ2Tg6Vpg==",
|
"integrity": "sha512-lxlDiartLPhVjQiz88biCYa8+ZAv3dOOS0KXQnYn6M79UrO6cCcSO+o3QwEKMvwhiCEp0BcH7wnypU/GOXoB1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/clerk-js": "^5.114.1",
|
"@clerk/clerk-js": "^5.105.1",
|
||||||
"@clerk/clerk-react": "^5.58.1",
|
"@clerk/clerk-react": "^5.53.8",
|
||||||
"@clerk/shared": "^3.39.0",
|
"@clerk/shared": "^3.31.1",
|
||||||
"@clerk/types": "^4.101.6",
|
"@clerk/types": "^4.97.2",
|
||||||
"base-64": "^1.0.0",
|
"base-64": "^1.0.0",
|
||||||
"react-native-url-polyfill": "2.0.0",
|
"react-native-url-polyfill": "2.0.0",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -1610,14 +1611,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/clerk-js": {
|
"node_modules/@clerk/clerk-js": {
|
||||||
"version": "5.114.1",
|
"version": "5.105.1",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-js/-/clerk-js-5.114.1.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/clerk-js/-/clerk-js-5.105.1.tgz",
|
||||||
"integrity": "sha512-l3A9UZKWOI7rCz/P9o2bS4hSTRLzPqr9eAPxhBFsfUsGgzlxQjVeyRQ2XgOXfvwsgekmulVHzlbvGZidT7JF6A==",
|
"integrity": "sha512-3ObGAARYsDjN4KS/FmCYvHL4FJtWjUdlDinUX1p1tPkFopPypiK9yhU+HSadpFTseLecc5TBnvuotJAAkQ2gGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-org/account": "2.0.1",
|
"@base-org/account": "2.0.1",
|
||||||
"@clerk/localizations": "^3.30.1",
|
"@clerk/localizations": "^3.27.0",
|
||||||
"@clerk/shared": "^3.39.0",
|
"@clerk/shared": "^3.31.1",
|
||||||
"@coinbase/wallet-sdk": "4.3.0",
|
"@coinbase/wallet-sdk": "4.3.0",
|
||||||
"@emotion/cache": "11.11.0",
|
"@emotion/cache": "11.11.0",
|
||||||
"@emotion/react": "11.11.1",
|
"@emotion/react": "11.11.1",
|
||||||
@@ -1637,7 +1638,8 @@
|
|||||||
"dequal": "2.0.3",
|
"dequal": "2.0.3",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1",
|
||||||
|
"swr": "2.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17.0"
|
"node": ">=18.17.0"
|
||||||
@@ -1664,12 +1666,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/clerk-react": {
|
"node_modules/@clerk/clerk-react": {
|
||||||
"version": "5.58.1",
|
"version": "5.53.8",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.58.1.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.53.8.tgz",
|
||||||
"integrity": "sha512-jN6mfuqwZakm99CKRQlTahMZEa8qLOpr3Z4lG6XtyJfIcyADleOdRxuOXYoN9sV8ZhNfDDAs+eKjUeusIzPpbg==",
|
"integrity": "sha512-TOiYk31rQUL9JOKZr/fhajf+fQCHicy1J4Rxq7vqtjHseJsnIBjzTigjOap/w8PrDAF28O6dbPC5CA0Tp7Md8w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/shared": "^3.39.0",
|
"@clerk/shared": "^3.31.1",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1681,21 +1683,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/localizations": {
|
"node_modules/@clerk/localizations": {
|
||||||
"version": "3.30.1",
|
"version": "3.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/localizations/-/localizations-3.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/localizations/-/localizations-3.27.0.tgz",
|
||||||
"integrity": "sha512-CHatnmQsGggsBMOO3k82oEh9E7j8Xs/vjFtx+hwGwM/qcJ6CEPmP69QKeRBfAXY5PcJinAuyYmFEqMEFvbyjSw==",
|
"integrity": "sha512-XQrzOtON32dXlTHEUwL0utTro6MePSM1gljCjRcY6RKh36I2BcuuG2XVu6K2HIvBL+Ef+nWF8+aU4+zc14nVcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/types": "^4.101.6"
|
"@clerk/types": "^4.97.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17.0"
|
"node": ">=18.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/shared": {
|
"node_modules/@clerk/shared": {
|
||||||
"version": "3.39.0",
|
"version": "3.31.1",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.39.0.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.31.1.tgz",
|
||||||
"integrity": "sha512-9kqqXGMPAdMQ7SXo5ZwUhbzbLLQeLp/1jdb8FQS5qlhmL0S0bAYKcyDjcmMB8xZPXNc7vZJRT72QHsGSlUAJxw==",
|
"integrity": "sha512-mqxZqlzLJYJxA+ryLzhwFR0eO73teAvRd+wvA8bLUZLYvCRFvaiHsB9dEvbo9Z5bMYdq3NPwnx2uljMuu/tiQw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1723,12 +1725,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/types": {
|
"node_modules/@clerk/types": {
|
||||||
"version": "4.101.6",
|
"version": "4.97.2",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.6.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.97.2.tgz",
|
||||||
"integrity": "sha512-Ah6R65loy5Aq1jBpWo3x01IOlYJfgVn4LVAkSXKVQDtehn8w1bT2uJ7BTa2zH72A8F4K2HivkmMQa+v/37tLFA==",
|
"integrity": "sha512-xnJq3xzpmuuDnNnWuUMKJLPPkaEaLDM0kiv2Hm0gKIcL1+1P3VaGf2vL9roIhmhLswB2PUwtVvZKBmGjT5yOVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/shared": "^3.39.0"
|
"@clerk/shared": "^3.31.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17.0"
|
"node": ">=18.17.0"
|
||||||
@@ -2153,40 +2155,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/config": {
|
"node_modules/@expo/config": {
|
||||||
"version": "12.0.12",
|
"version": "12.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.10.tgz",
|
||||||
"integrity": "sha512-X2MW86+ulLpMGvdgfvpl2EOBAKUlwvnvoPwdaZeeyWufGopn1nTUeh4C9gMsplDaP1kXv9sLXVhOoUoX6g9PvQ==",
|
"integrity": "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "~7.10.4",
|
"@babel/code-frame": "~7.10.4",
|
||||||
"@expo/config-plugins": "~54.0.3",
|
"@expo/config-plugins": "~54.0.2",
|
||||||
"@expo/config-types": "^54.0.10",
|
"@expo/config-types": "^54.0.8",
|
||||||
"@expo/json-file": "^10.0.8",
|
"@expo/json-file": "^10.0.7",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"getenv": "^2.0.0",
|
"getenv": "^2.0.0",
|
||||||
"glob": "^13.0.0",
|
"glob": "^10.4.2",
|
||||||
"require-from-string": "^2.0.2",
|
"require-from-string": "^2.0.2",
|
||||||
"resolve-from": "^5.0.0",
|
"resolve-from": "^5.0.0",
|
||||||
"resolve-workspace-root": "^2.0.0",
|
"resolve-workspace-root": "^2.0.0",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"slugify": "^1.3.4",
|
"slugify": "^1.3.4",
|
||||||
"sucrase": "~3.35.1"
|
"sucrase": "3.35.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/config-plugins": {
|
"node_modules/@expo/config-plugins": {
|
||||||
"version": "54.0.3",
|
"version": "54.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.2.tgz",
|
||||||
"integrity": "sha512-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw==",
|
"integrity": "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/config-types": "^54.0.9",
|
"@expo/config-types": "^54.0.8",
|
||||||
"@expo/json-file": "~10.0.7",
|
"@expo/json-file": "~10.0.7",
|
||||||
"@expo/plist": "^0.4.7",
|
"@expo/plist": "^0.4.7",
|
||||||
"@expo/sdk-runtime-versions": "^1.0.0",
|
"@expo/sdk-runtime-versions": "^1.0.0",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
"debug": "^4.3.5",
|
"debug": "^4.3.5",
|
||||||
"getenv": "^2.0.0",
|
"getenv": "^2.0.0",
|
||||||
"glob": "^13.0.0",
|
"glob": "^10.4.2",
|
||||||
"resolve-from": "^5.0.0",
|
"resolve-from": "^5.0.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"slash": "^3.0.0",
|
"slash": "^3.0.0",
|
||||||
@@ -2196,57 +2198,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/config-plugins/node_modules/glob": {
|
"node_modules/@expo/config-plugins/node_modules/glob": {
|
||||||
"version": "13.0.0",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
|
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^10.1.1",
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
"minipass": "^7.1.2",
|
"minipass": "^7.1.2",
|
||||||
"path-scurry": "^2.0.0"
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"bin": {
|
||||||
"node": "20 || >=22"
|
"glob": "dist/esm/bin.mjs"
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/config-plugins/node_modules/lru-cache": {
|
|
||||||
"version": "11.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/config-plugins/node_modules/minimatch": {
|
|
||||||
"version": "10.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
|
||||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/brace-expansion": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/config-plugins/node_modules/path-scurry": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^11.0.0",
|
|
||||||
"minipass": "^7.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
@@ -2265,9 +2230,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/config-types": {
|
"node_modules/@expo/config-types": {
|
||||||
"version": "54.0.10",
|
"version": "54.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.8.tgz",
|
||||||
"integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==",
|
"integrity": "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@expo/config/node_modules/@babel/code-frame": {
|
"node_modules/@expo/config/node_modules/@babel/code-frame": {
|
||||||
@@ -2280,57 +2245,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/config/node_modules/glob": {
|
"node_modules/@expo/config/node_modules/glob": {
|
||||||
"version": "13.0.0",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
|
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^10.1.1",
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
"minipass": "^7.1.2",
|
"minipass": "^7.1.2",
|
||||||
"path-scurry": "^2.0.0"
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"bin": {
|
||||||
"node": "20 || >=22"
|
"glob": "dist/esm/bin.mjs"
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/config/node_modules/lru-cache": {
|
|
||||||
"version": "11.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/config/node_modules/minimatch": {
|
|
||||||
"version": "10.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
|
||||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/brace-expansion": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/config/node_modules/path-scurry": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^11.0.0",
|
|
||||||
"minipass": "^7.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
@@ -2410,9 +2338,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/env": {
|
"node_modules/@expo/env": {
|
||||||
"version": "2.0.8",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.7.tgz",
|
||||||
"integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==",
|
"integrity": "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^4.0.0",
|
"chalk": "^4.0.0",
|
||||||
@@ -2507,9 +2435,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/json-file": {
|
"node_modules/@expo/json-file": {
|
||||||
"version": "10.0.8",
|
"version": "10.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.7.tgz",
|
||||||
"integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==",
|
"integrity": "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "~7.10.4",
|
"@babel/code-frame": "~7.10.4",
|
||||||
@@ -2952,27 +2880,6 @@
|
|||||||
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
|
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/balanced-match": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@isaacs/brace-expansion": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/balanced-match": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -5116,9 +5023,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/abitype": {
|
"node_modules/abitype": {
|
||||||
"version": "1.2.2",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.1.1.tgz",
|
||||||
"integrity": "sha512-4DOIMWscIB3j8hboLAUjLZCE8TMLdgecBpHFumfU4PdO/C1SBCVx4Nu1wPYXaL2iK8B0Jk3tiwnDLCpUtm3fZg==",
|
"integrity": "sha512-Loe5/6tAgsBukY95eGaPSDmQHIjRZYQq8PB1MpsNccDIK8WiV+Uw6WzaIXipvaxTEL2yEB0OpEaQv3gs8pkS9Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/wevm"
|
"url": "https://github.com/sponsors/wevm"
|
||||||
@@ -7257,9 +7164,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-application": {
|
"node_modules/expo-application": {
|
||||||
"version": "7.0.8",
|
"version": "7.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.7.tgz",
|
||||||
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
|
"integrity": "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*"
|
"expo": "*"
|
||||||
@@ -7281,16 +7188,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-auth-session": {
|
"node_modules/expo-auth-session": {
|
||||||
"version": "7.0.10",
|
"version": "7.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.9.tgz",
|
||||||
"integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==",
|
"integrity": "sha512-mPSwaRWOJYas160lXi5P/7BkLy0xbh+er+IMmAYHqf2+iz2WWs9W/4lMAklQVJG2mCyOZi24XrkffvB2izCa1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"expo-application": "~7.0.8",
|
"expo-application": "~7.0.7",
|
||||||
"expo-constants": "~18.0.11",
|
"expo-constants": "~18.0.10",
|
||||||
"expo-crypto": "~15.0.8",
|
"expo-crypto": "~15.0.7",
|
||||||
"expo-linking": "~8.0.10",
|
"expo-linking": "~8.0.9",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.9",
|
||||||
"invariant": "^2.2.4"
|
"invariant": "^2.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -7331,13 +7238,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.11",
|
"version": "18.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz",
|
||||||
"integrity": "sha512-xnfrfZ7lHjb+03skhmDSYeFF7OU2K3Xn/lAeP+7RhkV2xp2f5RCKtOUYajCnYeZesvMrsUxOsbGOP2JXSOH3NA==",
|
"integrity": "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/config": "~12.0.11",
|
"@expo/config": "~12.0.10",
|
||||||
"@expo/env": "~2.0.8"
|
"@expo/env": "~2.0.7"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*",
|
"expo": "*",
|
||||||
@@ -7345,9 +7252,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-crypto": {
|
"node_modules/expo-crypto": {
|
||||||
"version": "15.0.8",
|
"version": "15.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.7.tgz",
|
||||||
"integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
|
"integrity": "sha512-FUo41TwwGT2e5rA45PsjezI868Ch3M6wbCZsmqTWdF/hr+HyPcrp1L//dsh/hsrsyrQdpY/U96Lu71/wXePJeg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.3.0"
|
"base64-js": "^1.3.0"
|
||||||
@@ -7410,12 +7317,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-linking": {
|
"node_modules/expo-linking": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz",
|
||||||
"integrity": "sha512-0EKtn4Sk6OYmb/5ZqK8riO0k1Ic+wyT3xExbmDvUYhT7p/cKqlVUExMuOIAt3Cx3KUUU1WCgGmdd493W/D5XjA==",
|
"integrity": "sha512-a0UHhlVyfwIbn8b1PSFPoFiIDJeps2iEq109hVH3CHd0CMKuRxFfNio9Axe2BjXhiJCYWR4OV1iIyzY/GjiVkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"expo-constants": "~18.0.11",
|
"expo-constants": "~18.0.10",
|
||||||
"invariant": "^2.2.4"
|
"invariant": "^2.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -7423,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",
|
||||||
@@ -7556,9 +7472,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-secure-store": {
|
"node_modules/expo-secure-store": {
|
||||||
"version": "15.0.8",
|
"version": "15.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.7.tgz",
|
||||||
"integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==",
|
"integrity": "sha512-9q7+G1Zxr5P6J5NRIlm86KulvmYwc6UnQlYPjQLDu1drDnerz6AT6l884dPu29HgtDTn4rR0heYeeGFhMKM7/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*"
|
"expo": "*"
|
||||||
@@ -7587,9 +7503,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-web-browser": {
|
"node_modules/expo-web-browser": {
|
||||||
"version": "15.0.10",
|
"version": "15.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.9.tgz",
|
||||||
"integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==",
|
"integrity": "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*",
|
"expo": "*",
|
||||||
@@ -7735,23 +7651,6 @@
|
|||||||
"asap": "~2.0.3"
|
"asap": "~2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fdir": {
|
|
||||||
"version": "6.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"picomatch": "^3 || ^4"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"picomatch": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -12897,17 +12796,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.2",
|
"@jridgewell/gen-mapping": "^0.3.2",
|
||||||
"commander": "^4.0.0",
|
"commander": "^4.0.0",
|
||||||
|
"glob": "^10.3.10",
|
||||||
"lines-and-columns": "^1.1.6",
|
"lines-and-columns": "^1.1.6",
|
||||||
"mz": "^2.7.0",
|
"mz": "^2.7.0",
|
||||||
"pirates": "^4.0.1",
|
"pirates": "^4.0.1",
|
||||||
"tinyglobby": "^0.2.11",
|
|
||||||
"ts-interface-checker": "^0.1.9"
|
"ts-interface-checker": "^0.1.9"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -12927,6 +12826,26 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sucrase/node_modules/glob": {
|
||||||
|
"version": "10.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
|
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -13127,34 +13046,6 @@
|
|||||||
"integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==",
|
"integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
|
||||||
"version": "0.2.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fdir": "^6.5.0",
|
|
||||||
"picomatch": "^4.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tmpl": {
|
"node_modules/tmpl": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
@@ -13548,9 +13439,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/viem": {
|
"node_modules/viem": {
|
||||||
"version": "2.41.2",
|
"version": "2.38.6",
|
||||||
"resolved": "https://registry.npmjs.org/viem/-/viem-2.41.2.tgz",
|
"resolved": "https://registry.npmjs.org/viem/-/viem-2.38.6.tgz",
|
||||||
"integrity": "sha512-LYliajglBe1FU6+EH9mSWozp+gRA/QcHfxeD9Odf83AdH5fwUS7DroH4gHvlv6Sshqi1uXrYFA2B/EOczxd15g==",
|
"integrity": "sha512-aqO6P52LPXRjdnP6rl5Buab65sYa4cZ6Cpn+k4OLOzVJhGIK8onTVoKMFMT04YjDfyDICa/DZyV9HmvLDgcjkw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/clerk-expo": "^2.19.11",
|
"@clerk/clerk-expo": "^2.18.3",
|
||||||
"@expo/vector-icons": "^15.0.0",
|
"@expo/vector-icons": "^15.0.0",
|
||||||
"@hookform/resolvers": "^3.3.0",
|
"@hookform/resolvers": "^3.3.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
@@ -25,22 +25,23 @@
|
|||||||
"ajv-keywords": "^5.1.0",
|
"ajv-keywords": "^5.1.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"expo": "~54.0.23",
|
"expo": "~54.0.23",
|
||||||
"expo-auth-session": "^7.0.10",
|
"expo-auth-session": "^7.0.8",
|
||||||
"expo-barcode-scanner": "^13.0.1",
|
"expo-barcode-scanner": "^13.0.1",
|
||||||
"expo-camera": "~17.0.9",
|
"expo-camera": "~17.0.9",
|
||||||
"expo-constants": "^18.0.10",
|
"expo-constants": "^18.0.10",
|
||||||
"expo-crypto": "^15.0.8",
|
"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",
|
||||||
"expo-status-bar": "^3.0.8",
|
"expo-status-bar": "^3.0.8",
|
||||||
"expo-web-browser": "^15.0.10",
|
"expo-web-browser": "^15.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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 { 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() {
|
||||||
@@ -109,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 ? (
|
||||||
@@ -131,6 +138,9 @@ export default function HomeScreen() {
|
|||||||
duration={45}
|
duration={45}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Performance Metrics */}
|
||||||
|
<PerformanceMetrics />
|
||||||
|
|
||||||
{/* Hydration Widget */}
|
{/* Hydration Widget */}
|
||||||
<HydrationWidget
|
<HydrationWidget
|
||||||
current={waterIntake}
|
current={waterIntake}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { theme } from "../../styles/theme";
|
|||||||
interface Recommendation {
|
interface Recommendation {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
recommendationText: string;
|
content: string;
|
||||||
activityPlan?: string;
|
activityPlan?: string;
|
||||||
dietPlan?: string;
|
dietPlan?: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -141,7 +141,7 @@ export default function RecommendationsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
||||||
<Text style={styles.content}>{item.recommendationText}</Text>
|
<Text style={styles.content}>{item.content}</Text>
|
||||||
|
|
||||||
{item.activityPlan && (
|
{item.activityPlan && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, Alert } from 'react-native';
|
import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, Alert, Platform } from 'react-native';
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
@@ -20,7 +20,7 @@ const FOOD_DATABASE: { [key: string]: { name: string; calories: number; servingS
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProps) {
|
export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProps) {
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = Platform.OS === 'web' ? [null, null] : useCameraPermissions() as any;
|
||||||
const [scanned, setScanned] = useState(false);
|
const [scanned, setScanned] = useState(false);
|
||||||
const [foodData, setFoodData] = useState<{ name: string; calories: number; servingSize: string } | null>(null);
|
const [foodData, setFoodData] = useState<{ name: string; calories: number; servingSize: string } | null>(null);
|
||||||
const [servings, setServings] = useState('1');
|
const [servings, setServings] = useState('1');
|
||||||
@@ -68,10 +68,29 @@ export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProp
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!permission) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!permission.granted) {
|
if (!permission.granted && Platform.OS !== 'web') {
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="slide">
|
<Modal visible={visible} transparent animationType="slide">
|
||||||
<View style={styles.permissionContainer}>
|
<View style={styles.permissionContainer}>
|
||||||
@@ -111,19 +130,25 @@ export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProp
|
|||||||
<View style={{ width: 28 }} />
|
<View style={{ width: 28 }} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<CameraView
|
{Platform.OS === 'web' ? (
|
||||||
style={styles.camera}
|
<View style={styles.webPlaceholder}>
|
||||||
facing="back"
|
<Text style={styles.webText}>Barcode scanning is only available on mobile devices</Text>
|
||||||
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>
|
</View>
|
||||||
</CameraView>
|
) : (
|
||||||
|
<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.resultContainer}>
|
||||||
@@ -230,6 +255,18 @@ const styles = StyleSheet.create({
|
|||||||
camera: {
|
camera: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
webPlaceholder: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
},
|
||||||
|
webText: {
|
||||||
|
color: '#999',
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
scanOverlay: {
|
scanOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const API_BASE_URL = __DEV__
|
export const API_BASE_URL = __DEV__
|
||||||
? 'https://5e424f097c8b.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
|
||||||
|
```
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Analysis and Next Steps
|
|
||||||
|
|
||||||
## Current State Analysis
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
- **Monorepo Structure**: The project is well-structured as a monorepo with `apps/admin` (Next.js), `apps/mobile` (Expo), and shared packages (`database`, `shared`).
|
|
||||||
- **Database Inconsistency**: There is a significant architectural inconsistency in database access.
|
|
||||||
- `packages/database` defines a Drizzle ORM schema and exports a Drizzle instance.
|
|
||||||
- `apps/admin` implements a custom `SQLiteDatabase` class (`src/lib/database/sqlite.ts`) that uses `better-sqlite3` directly with raw SQL queries and manual object mapping.
|
|
||||||
- This leads to code duplication, potential schema drift (if Drizzle schema changes but raw SQL doesn't), and loss of Drizzle's type safety and convenience features.
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- **Type Safety**:
|
|
||||||
- `UserManagement.tsx` defines local interfaces (e.g., `User`) that duplicate the schema definitions.
|
|
||||||
- Shared types should be utilized from `@fitai/shared` or `@fitai/database`.
|
|
||||||
- **Configuration**:
|
|
||||||
- `apps/mobile/src/config/api.ts` contains hardcoded URLs (e.g., ngrok) which is brittle for development and production.
|
|
||||||
- API routes in `apps/admin` contain `console.log` statements that should be removed or replaced with a proper logging solution.https://5e424f097c8b.ngrok-free.app
|
|
||||||
- **Error Handling**:
|
|
||||||
- Basic error handling exists in API routes, but could be standardized (e.g., custom error classes, consistent error response format).
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- **Missing Tests**: While `jest` is configured in `apps/admin`, there are no actual test files in `src`. The critical business logic in `src/lib/database` and API routes is currently untested.
|
|
||||||
|
|
||||||
## Suggested Next Steps
|
|
||||||
|
|
||||||
### 1. Unify Database Access (High Priority)
|
|
||||||
Refactor `apps/admin` to use the Drizzle ORM instance from `@fitai/database` instead of the custom `SQLiteDatabase` class.
|
|
||||||
- **Benefits**: Removes ~800 lines of raw SQL code, ensures type safety, reduces maintenance burden, and leverages Drizzle's features.
|
|
||||||
- **Action**: Replace `src/lib/database/sqlite.ts` methods with Drizzle queries using the schema from `@fitai/database`.
|
|
||||||
|
|
||||||
### 2. Implement Testing Strategy (High Priority)
|
|
||||||
Start adding tests for critical paths.
|
|
||||||
- **Unit Tests**: Add Jest tests for the database logic (if keeping the custom class temporarily) or the service layer.
|
|
||||||
- **Integration Tests**: Test API routes (`/api/attendance/*`, `/api/users`) to ensure they handle requests correctly.
|
|
||||||
- **Action**: Create `__tests__` directories in `apps/admin/src` and add initial tests for attendance flows.
|
|
||||||
|
|
||||||
### 3. Improve Configuration Management (Medium Priority)
|
|
||||||
- **Mobile**: Use environment variables (e.g., `EXPO_PUBLIC_API_URL`) for the API base URL in the mobile app instead of hardcoding.
|
|
||||||
- **Admin**: Ensure all secrets and config values are strictly in `.env` files.
|
|
||||||
|
|
||||||
### 4. Enhance Type Safety (Medium Priority)
|
|
||||||
- **Shared Types**: Move the `User` interface and other shared entities from `UserManagement.tsx` to `@fitai/shared` or use the inferred types from Drizzle schema in `@fitai/database`.
|
|
||||||
- **Action**: Refactor components to import types from the shared package.
|
|
||||||
|
|
||||||
### 5. Cleanup and Standardization (Low Priority)
|
|
||||||
- Remove `console.log` from production code.
|
|
||||||
- Standardize API error responses.
|
|
||||||
|
|
||||||
## Immediate Action Items
|
|
||||||
1. **Create a reproduction test case** for the current attendance flow to ensure no regressions during refactoring.
|
|
||||||
2. **Refactor one module** (e.g., Attendance) to use Drizzle ORM as a proof of concept for the migration.
|
|
||||||
3. **Extract types** from `UserManagement.tsx` to `@fitai/shared`.
|
|
||||||
Generated
+2
-5
@@ -991,7 +991,6 @@
|
|||||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.3",
|
"@typescript-eslint/scope-manager": "8.46.3",
|
||||||
"@typescript-eslint/types": "8.46.3",
|
"@typescript-eslint/types": "8.46.3",
|
||||||
@@ -1197,7 +1196,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1630,7 +1628,6 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -2795,7 +2792,8 @@
|
|||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
@@ -3134,7 +3132,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
Generated
+1
-4
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"better-sqlite3": "12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"drizzle-orm": "^0.44.7"
|
"drizzle-orm": "^0.44.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -907,7 +907,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -947,7 +946,6 @@
|
|||||||
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
|
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"prebuild-install": "^7.1.1"
|
"prebuild-install": "^7.1.1"
|
||||||
@@ -1221,7 +1219,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"better-sqlite3": "12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"@types/better-sqlite3": "^7.6.13"
|
"@types/better-sqlite3": "^7.6.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ const sqlite = new Database(dbPath)
|
|||||||
export const db = drizzle(sqlite, { schema })
|
export const db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
export { eq, and, or, desc, asc, sql } from 'drizzle-orm'
|
export { eq, and, or, desc, asc } from 'drizzle-orm'
|
||||||
@@ -74,9 +74,9 @@ export const payments = sqliteTable("payments", {
|
|||||||
|
|
||||||
export const attendance = sqliteTable("attendance", {
|
export const attendance = sqliteTable("attendance", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id")
|
clientId: text("client_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
checkInTime: integer("check_in_time", { mode: "timestamp" }).notNull(),
|
checkInTime: integer("check_in_time", { mode: "timestamp" }).notNull(),
|
||||||
checkOutTime: integer("check_out_time", { mode: "timestamp" }),
|
checkOutTime: integer("check_out_time", { mode: "timestamp" }),
|
||||||
type: text("type", { enum: ["gym", "class", "personal_training"] })
|
type: text("type", { enum: ["gym", "class", "personal_training"] })
|
||||||
@@ -116,7 +116,15 @@ export const fitnessProfiles = sqliteTable("fitness_profiles", {
|
|||||||
gender: text("gender", {
|
gender: text("gender", {
|
||||||
enum: ["male", "female", "other", "prefer_not_to_say"],
|
enum: ["male", "female", "other", "prefer_not_to_say"],
|
||||||
}),
|
}),
|
||||||
fitnessGoals: text("fitness_goals", { mode: "json" }).$type<string[]>(),
|
fitnessGoal: text("fitness_goal", {
|
||||||
|
enum: [
|
||||||
|
"weight_loss",
|
||||||
|
"muscle_gain",
|
||||||
|
"endurance",
|
||||||
|
"flexibility",
|
||||||
|
"general_fitness",
|
||||||
|
],
|
||||||
|
}),
|
||||||
activityLevel: text("activity_level", {
|
activityLevel: text("activity_level", {
|
||||||
enum: [
|
enum: [
|
||||||
"sedentary",
|
"sedentary",
|
||||||
|
|||||||
Generated
-21
@@ -8,33 +8,12 @@
|
|||||||
"name": "@fitai/shared",
|
"name": "@fitai/shared",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/clsx": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tailwind-merge": {
|
|
||||||
"version": "3.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
|
||||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/dcastil"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|||||||
@@ -9,11 +9,9 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,7 @@ export interface User {
|
|||||||
email: string
|
email: string
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
password?: string
|
role: 'admin' | 'trainer' | 'client'
|
||||||
phone?: string
|
|
||||||
role: 'superAdmin' | 'admin' | 'trainer' | 'client'
|
|
||||||
imageUrl?: string
|
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
@@ -14,83 +11,18 @@ export interface User {
|
|||||||
export interface Client {
|
export interface Client {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
user?: User
|
user: User
|
||||||
membershipType: 'basic' | 'premium' | 'vip'
|
membershipType: 'basic' | 'premium' | 'vip'
|
||||||
membershipStatus: 'active' | 'inactive' | 'suspended' | 'expired'
|
membershipStatus: 'active' | 'inactive' | 'suspended'
|
||||||
joinDate: Date
|
joinDate: Date
|
||||||
lastVisit?: Date
|
lastVisit?: Date
|
||||||
emergencyContact?: {
|
emergencyContact: {
|
||||||
name: string
|
name: string
|
||||||
phone: string
|
phone: string
|
||||||
relationship: string
|
relationship: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FitnessProfile {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
height: string
|
|
||||||
weight: string
|
|
||||||
age: string
|
|
||||||
gender: "male" | "female" | "other"
|
|
||||||
activityLevel: "sedentary" | "lightly_active" | "moderately_active" | "very_active" | "extremely_active"
|
|
||||||
fitnessGoals: string[]
|
|
||||||
exerciseHabits: string
|
|
||||||
dietHabits: string
|
|
||||||
medicalConditions: string
|
|
||||||
allergies?: string
|
|
||||||
injuries?: string
|
|
||||||
createdAt: Date
|
|
||||||
updatedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Attendance {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
clientId?: string
|
|
||||||
client?: Client
|
|
||||||
checkInTime: Date
|
|
||||||
checkOutTime?: Date
|
|
||||||
type: 'gym' | 'class' | 'personal_training'
|
|
||||||
notes?: string
|
|
||||||
createdAt?: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Recommendation {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
fitnessProfileId?: string
|
|
||||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan"
|
|
||||||
recommendationText: string
|
|
||||||
activityPlan?: string
|
|
||||||
dietPlan?: string
|
|
||||||
status: "pending" | "approved" | "rejected" | "completed"
|
|
||||||
createdAt: Date
|
|
||||||
approvedAt?: Date
|
|
||||||
approvedBy?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FitnessGoal {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
fitnessProfileId?: string
|
|
||||||
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom"
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
targetValue?: number
|
|
||||||
currentValue?: number
|
|
||||||
unit?: string
|
|
||||||
startDate: Date
|
|
||||||
targetDate?: Date
|
|
||||||
completedDate?: Date
|
|
||||||
status: "active" | "completed" | "abandoned" | "paused"
|
|
||||||
progress: number
|
|
||||||
priority: "low" | "medium" | "high"
|
|
||||||
notes?: string
|
|
||||||
createdAt: Date
|
|
||||||
updatedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: string
|
id: string
|
||||||
clientId: string
|
clientId: string
|
||||||
@@ -104,6 +36,16 @@ export interface Payment {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Attendance {
|
||||||
|
id: string
|
||||||
|
clientId: string
|
||||||
|
client: Client
|
||||||
|
checkInTime: Date
|
||||||
|
checkOutTime?: Date
|
||||||
|
type: 'gym' | 'class' | 'personal_training'
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
export const formatDate = (date: Date): string => {
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatDate = (date: Date | string): string => {
|
|
||||||
const d = new Date(date)
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
}).format(d)
|
}).format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatCurrency = (
|
export const formatCurrency = (
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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