Compare commits
2
Commits
fitnessProfile
...
agv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39021dca35 | ||
|
|
33a698066f |
No files matched your search
Binary file not shown.
Generated
+11
@@ -22,6 +22,7 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -4931,6 +4932,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
const user = await db.getUserById(userId)
|
||||
|
||||
if (!user || user.role !== 'admin') {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const attendance = await db.getAllAttendance()
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Admin attendance error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { ensureUserSynced } from '@/lib/sync-user'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db)
|
||||
|
||||
let client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
// Auto-create client profile if it doesn't exist
|
||||
console.log('Client profile not found, creating new one for user:', userId)
|
||||
client = await db.createClient({
|
||||
userId,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
// Check if already checked in
|
||||
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
||||
if (activeCheckIn) {
|
||||
return new NextResponse('Already checked in', { status: 400 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { type = 'gym', notes } = body
|
||||
|
||||
const attendance = await db.checkIn(client.id, type, notes)
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Check-in error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
const client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
return new NextResponse('Client profile not found', { status: 404 })
|
||||
}
|
||||
|
||||
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
||||
if (!activeCheckIn) {
|
||||
return new NextResponse('No active check-in found', { status: 404 })
|
||||
}
|
||||
|
||||
const attendance = await db.checkOut(activeCheckIn.id)
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Check-out error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { ensureUserSynced } from '@/lib/sync-user'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
console.log('GET /api/attendance/history called')
|
||||
console.log('Headers:', Object.fromEntries(req.headers.entries()))
|
||||
|
||||
try {
|
||||
const authResult = await auth()
|
||||
const { userId } = authResult
|
||||
console.log('Auth result:', JSON.stringify(authResult, null, 2))
|
||||
|
||||
if (!userId) {
|
||||
console.log('No userId found in auth result')
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const db = await getDatabase()
|
||||
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db)
|
||||
|
||||
let client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
// Auto-create client profile if it doesn't exist
|
||||
console.log('Client profile not found, creating new one for user:', userId)
|
||||
client = await db.createClient({
|
||||
userId,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
const history = await db.getAttendanceHistory(client.id)
|
||||
return NextResponse.json(history)
|
||||
} catch (error) {
|
||||
console.error('History error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface Attendance {
|
||||
id: string
|
||||
clientId: string
|
||||
checkInTime: string
|
||||
checkOutTime?: string
|
||||
type: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export default function AttendancePage() {
|
||||
const [attendance, setAttendance] = useState<Attendance[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAttendance = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/attendance')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAttendance(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching attendance:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAttendance()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Attendance Monitoring</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Client ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Check In
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Check Out
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{attendance.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{record.clientId}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">
|
||||
{record.type}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{format(new Date(record.checkInTime), 'PP p')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{record.checkOutTime ? format(new Date(record.checkOutTime), 'PP p') : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${record.checkOutTime
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{record.checkOutTime ? 'Completed' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
password: string
|
||||
phone?: string
|
||||
role: 'admin' | 'client'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: string
|
||||
userId: string
|
||||
membershipType: 'basic' | 'premium' | 'vip'
|
||||
membershipStatus: 'active' | 'inactive' | 'expired'
|
||||
joinDate: 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
|
||||
}
|
||||
|
||||
// In-memory database
|
||||
export const users: User[] = []
|
||||
export const clients: Client[] = []
|
||||
export const fitnessProfiles: FitnessProfile[] = []
|
||||
@@ -1,7 +1,7 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { IDatabase, User, Client, FitnessProfile, DatabaseConfig } from './types'
|
||||
import { IDatabase, User, Client, FitnessProfile, Attendance, DatabaseConfig } from './types'
|
||||
|
||||
export class SQLiteDatabase implements IDatabase {
|
||||
private db: Database.Database | null = null
|
||||
@@ -14,21 +14,21 @@ export class SQLiteDatabase implements IDatabase {
|
||||
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)
|
||||
}
|
||||
@@ -103,18 +103,37 @@ export class SQLiteDatabase implements IDatabase {
|
||||
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
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance (
|
||||
id TEXT PRIMARY KEY,
|
||||
clientId TEXT NOT NULL,
|
||||
checkInTime DATETIME NOT NULL,
|
||||
checkOutTime DATETIME,
|
||||
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
|
||||
notes TEXT,
|
||||
createdAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (clientId) REFERENCES clients (id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
|
||||
`)
|
||||
}
|
||||
|
||||
// User operations
|
||||
async createUser(userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
|
||||
async createUser(userData: Omit<User, 'createdAt' | 'updatedAt'>): Promise<User> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
const id = userData.id || Math.random().toString(36).substr(2, 9)
|
||||
const now = new Date()
|
||||
|
||||
|
||||
const user: User = {
|
||||
id,
|
||||
...userData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
@@ -123,9 +142,9 @@ export class SQLiteDatabase implements IDatabase {
|
||||
`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.id, user.email, user.firstName, user.lastName, user.password,
|
||||
user.phone, user.role, user.createdAt.toISOString(), user.updatedAt.toISOString()
|
||||
)
|
||||
|
||||
@@ -195,9 +214,9 @@ export class SQLiteDatabase implements IDatabase {
|
||||
`INSERT INTO clients (id, userId, membershipType, membershipStatus, joinDate)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
|
||||
stmt.run(
|
||||
client.id, client.userId, client.membershipType,
|
||||
client.id, client.userId, client.membershipType,
|
||||
client.membershipStatus, client.joinDate.toISOString()
|
||||
)
|
||||
|
||||
@@ -269,7 +288,7 @@ export class SQLiteDatabase implements IDatabase {
|
||||
exerciseHabits, dietHabits, medicalConditions, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
|
||||
stmt.run(
|
||||
profile.userId, profile.height, profile.weight, profile.age, profile.gender,
|
||||
profile.activityLevel, JSON.stringify(profile.fitnessGoals), profile.exerciseHabits,
|
||||
@@ -325,6 +344,73 @@ export class SQLiteDatabase implements IDatabase {
|
||||
return (result.changes || 0) > 0
|
||||
}
|
||||
|
||||
// Attendance operations
|
||||
async checkIn(clientId: 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,
|
||||
clientId,
|
||||
checkInTime: now,
|
||||
type,
|
||||
notes,
|
||||
createdAt: now
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO attendance (id, clientId, checkInTime, type, notes, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
attendance.id, attendance.clientId, attendance.checkInTime.toISOString(),
|
||||
attendance.type, attendance.notes, attendance.createdAt.toISOString()
|
||||
)
|
||||
|
||||
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(clientId: string): Promise<Attendance[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? ORDER BY checkInTime DESC')
|
||||
const rows = stmt.all(clientId)
|
||||
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(clientId: string): Promise<Attendance | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
|
||||
const row = stmt.get(clientId)
|
||||
return row ? this.mapRowToAttendance(row) : null
|
||||
}
|
||||
|
||||
// Helper methods to map database rows to entities
|
||||
private mapRowToUser(row: any): User {
|
||||
return {
|
||||
@@ -366,4 +452,16 @@ export class SQLiteDatabase implements IDatabase {
|
||||
updatedAt: new Date(row.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private mapRowToAttendance(row: any): Attendance {
|
||||
return {
|
||||
id: row.id,
|
||||
clientId: row.clientId,
|
||||
checkInTime: new Date(row.checkInTime),
|
||||
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||
type: row.type,
|
||||
notes: row.notes,
|
||||
createdAt: new Date(row.createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@ export interface FitnessProfile {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
id: string;
|
||||
clientId: string;
|
||||
checkInTime: Date;
|
||||
checkOutTime?: Date;
|
||||
type: "gym" | "class" | "personal_training";
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Database Interface - allows us to swap implementations
|
||||
export interface IDatabase {
|
||||
// Connection management
|
||||
@@ -41,7 +51,7 @@ export interface IDatabase {
|
||||
disconnect(): Promise<void>;
|
||||
|
||||
// User operations
|
||||
createUser(user: Omit<User, "id" | "createdAt" | "updatedAt">): Promise<User>;
|
||||
createUser(user: Omit<User, "createdAt" | "updatedAt">): Promise<User>;
|
||||
getUserById(id: string): Promise<User | null>;
|
||||
getUserByEmail(email: string): Promise<User | null>;
|
||||
getAllUsers(): Promise<User[]>;
|
||||
@@ -67,6 +77,17 @@ export interface IDatabase {
|
||||
updates: Partial<FitnessProfile>,
|
||||
): Promise<FitnessProfile | null>;
|
||||
deleteFitnessProfile(userId: string): Promise<boolean>;
|
||||
|
||||
// Attendance operations
|
||||
checkIn(
|
||||
clientId: string,
|
||||
type: "gym" | "class" | "personal_training",
|
||||
notes?: string,
|
||||
): Promise<Attendance>;
|
||||
checkOut(attendanceId: string): Promise<Attendance | null>;
|
||||
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
|
||||
getAllAttendance(): Promise<Attendance[]>;
|
||||
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
|
||||
}
|
||||
|
||||
// Database configuration
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { currentUser } from '@clerk/nextjs/server'
|
||||
import { IDatabase } from './database/types'
|
||||
|
||||
export async function ensureUserSynced(userId: string, db: IDatabase) {
|
||||
const existingUser = await db.getUserById(userId)
|
||||
if (existingUser) return existingUser
|
||||
|
||||
console.log('User not found in DB, syncing from Clerk:', userId)
|
||||
const clerkUser = await currentUser()
|
||||
|
||||
if (!clerkUser || clerkUser.id !== userId) {
|
||||
throw new Error('Could not fetch Clerk user details')
|
||||
}
|
||||
|
||||
const email = clerkUser.emailAddresses[0]?.emailAddress
|
||||
if (!email) throw new Error('User has no email')
|
||||
|
||||
const user = 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') || 'client'
|
||||
})
|
||||
|
||||
return user
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const isPublicRoute = createRouteMatcher([
|
||||
"/sign-in(.*)",
|
||||
"/sign-up(.*)",
|
||||
"/api/webhooks(.*)",
|
||||
"/api/attendance(.*)",
|
||||
]);
|
||||
|
||||
// Define routes that require authentication
|
||||
@@ -16,7 +17,6 @@ const isProtectedRoute = createRouteMatcher([
|
||||
"/api/users(.*)",
|
||||
"/api/profile(.*)",
|
||||
"/api/payments(.*)",
|
||||
"/api/attendance(.*)",
|
||||
"/api/notifications(.*)",
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const API_BASE_URL = Platform.select({
|
||||
android: "http://10.0.2.2:3000", // Android emulator maps this to host's localhost
|
||||
ios: "http://localhost:3000",
|
||||
default: process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000",
|
||||
});
|
||||
import { API_BASE_URL } from "../config/api";
|
||||
|
||||
export interface FitnessProfile {
|
||||
id?: string;
|
||||
|
||||
@@ -1,28 +1,244 @@
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@clerk/clerk-expo'
|
||||
import axios from 'axios'
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../../config/api'
|
||||
|
||||
interface Attendance {
|
||||
id: string
|
||||
checkInTime: string
|
||||
checkOutTime?: string
|
||||
type: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export default function AttendanceScreen() {
|
||||
const { getToken, userId } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null)
|
||||
const [history, setHistory] = useState<Attendance[]>([])
|
||||
|
||||
const fetchAttendance = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const token = await getToken()
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.HISTORY}`
|
||||
console.log('Fetching attendance from:', url)
|
||||
const response = await axios.get(url, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
|
||||
const data: Attendance[] = response.data
|
||||
setHistory(data)
|
||||
|
||||
// Check if there's an active check-in (latest one has no checkOutTime)
|
||||
if (data.length > 0 && !data[0].checkOutTime) {
|
||||
setActiveCheckIn(data[0])
|
||||
} else {
|
||||
setActiveCheckIn(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching attendance:', error)
|
||||
Alert.alert('Error', 'Failed to load attendance data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttendance()
|
||||
}, [])
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
await axios.post(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.CHECK_IN}`,
|
||||
{ type: 'gym' },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked in successfully!')
|
||||
} catch (error: any) {
|
||||
console.error('Check-in error:', error)
|
||||
Alert.alert('Error', error.response?.data || 'Failed to check in')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
await axios.post(
|
||||
`${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.CHECK_OUT}`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked out successfully!')
|
||||
} catch (error: any) {
|
||||
console.error('Check-out error:', error)
|
||||
Alert.alert('Error', error.response?.data || 'Failed to check out')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !history.length) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color="#000" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Attendance</Text>
|
||||
<Text style={styles.subtitle}>Track your gym visits</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionContainer}>
|
||||
{activeCheckIn ? (
|
||||
<View style={styles.activeCard}>
|
||||
<Text style={styles.activeText}>Currently Checked In</Text>
|
||||
<Text style={styles.timeText}>
|
||||
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.checkOutButton} onPress={handleCheckOut}>
|
||||
<Text style={styles.buttonText}>Check Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity style={styles.checkInButton} onPress={handleCheckIn}>
|
||||
<Text style={styles.buttonText}>Check In</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Recent History</Text>
|
||||
{history.map((item) => (
|
||||
<View key={item.id} style={styles.historyItem}>
|
||||
<View>
|
||||
<Text style={styles.dateText}>
|
||||
{new Date(item.checkInTime).toLocaleDateString()}
|
||||
</Text>
|
||||
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
|
||||
</View>
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.historyTime}>
|
||||
In: {new Date(item.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
{item.checkOutTime && (
|
||||
<Text style={styles.historyTime}>
|
||||
Out: {new Date(item.checkOutTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
marginBottom: 4,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
},
|
||||
actionContainer: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
checkInButton: {
|
||||
backgroundColor: '#000',
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
checkOutButton: {
|
||||
backgroundColor: '#ff3b30',
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
activeCard: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
activeText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#2e7d32',
|
||||
marginBottom: 4,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
historyItem: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
typeText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
timeContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
historyTime: {
|
||||
fontSize: 14,
|
||||
color: '#444',
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
export const API_BASE_URL = __DEV__
|
||||
? 'http://192.168.1.24:3000'
|
||||
export const API_BASE_URL = __DEV__
|
||||
? 'https://5cb23f31d8c1.ngrok-free.app'
|
||||
: 'https://your-production-url.com'
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
@@ -12,4 +12,9 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
CLIENTS: '/api/clients',
|
||||
USERS: '/api/users',
|
||||
ATTENDANCE: {
|
||||
CHECK_IN: '/api/attendance/check-in',
|
||||
CHECK_OUT: '/api/attendance/check-out',
|
||||
HISTORY: '/api/attendance/history',
|
||||
},
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"lib": [
|
||||
"es2017"
|
||||
],
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"target": "esnext",
|
||||
@@ -35,4 +35,4 @@
|
||||
"jest.config.js"
|
||||
],
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
## business plan
|
||||
|
||||
we are building a complete fitness solution, with a admin nestjs app [where we will
|
||||
track attandace, check in and check out from the gym, we will track payments, get
|
||||
users fitness profile, traineers will prepare recommendations for client etc], and mobile
|
||||
react native app.
|
||||
we will have separate abstraction for db layer so we can easily switch db's for performance testing [we will start with sqlite].
|
||||
we will use clerk for auth.
|
||||
|
||||
# phase one
|
||||
|
||||
in phase one when user make account via mobile app he will be redirected to fitnees profile screen where he will complete form with fitness relevant infos, data will be saved to db and be accesible from nestjs admin app.
|
||||
we have cooperation with a gym [apx 100 clients], every client of the gym will be obligated to download the app and use it for check in and check out, also he must complete fitness profile.
|
||||
admin will be responsible for payments processing i.e check in input date such as date of the last payment... we will have notification system for activity and payments.
|
||||
|
||||
in later phases after creating acc user can choose a gym and trainer, and we will have ai subsytem/backend that will process user profile and creating recommendations [nestjs or go backend]
|
||||
|
||||
|
||||
---and
|
||||
# Executive Summary
|
||||
|
||||
Our company is developing a comprehensive fitness ecosystem called "FitTrack Pro" (placeholder name; customizable), aimed at bridging gyms, trainers, and users through seamless technology. The core offering includes a NestJS-based admin web application for gym management and a React Native mobile app for users. Key features encompass attendance tracking, check-in/check-out, payment management, user fitness profiles, and trainer recommendations, with future AI-driven personalization.
|
||||
|
||||
Starting with a pilot partnership with a single gym (approximately 100 clients), the platform mandates app usage for gym members, ensuring high adoption rates from day one. We leverage Clerk for secure authentication and begin with SQLite for the database, with an abstraction layer for easy scalability to more robust options like PostgreSQL.
|
||||
|
||||
The business model focuses on B2B partnerships with gyms (subscription fees for admin tools) and B2C monetization through premium user features (e.g., AI recommendations). Projected revenue in Year 1: $50,000 from the pilot gym and early expansions. We seek $200,000 in seed funding for development, marketing, and operations.
|
||||
|
||||
Our mission: Empower gyms to digitize operations while providing users with personalized fitness journeys, capitalizing on the growing digital fitness market valued at over $30 billion globally in 2025.
|
||||
|
||||
# Company Description
|
||||
|
||||
**Business Overview**: FitTrack Pro is a SaaS platform offering an integrated fitness management solution. The admin app (built with NestJS) handles backend operations like attendance tracking, payment processing, user profile management, and notifications. The user-facing mobile app (React Native) allows seamless account creation, profile setup, check-in/check-out, and access to personalized recommendations.
|
||||
|
||||
**Legal Structure**: Recommend starting as an LLC for flexibility and liability protection. Founders: [Your Name/Team] with expertise in software development (NestJS, React Native) and fitness industry knowledge.
|
||||
|
||||
**Location**: Initially remote/virtual, with potential for office space as the team grows. Target markets: Urban areas with high gym density, starting in [Your Region/Country, e.g., North America/Europe based on user info in MK (North Macedonia)].
|
||||
|
||||
**Mission Statement**: To revolutionize gym experiences by combining technology with personalized fitness, reducing administrative burdens for gyms and enhancing user engagement.
|
||||
|
||||
**Vision**: Expand to a network of 500+ gyms within 3 years, incorporating AI for predictive health insights and global user base of 1 million.
|
||||
|
||||
# Market Analysis
|
||||
|
||||
The digital fitness industry is booming, driven by post-pandemic health awareness, wearable tech integration, and demand for hybrid gym experiences. According to recent data:
|
||||
|
||||
- Global fitness app market size: Projected to reach $15.6 billion by 2026, growing at a CAGR of 21.6% from 2021 (source: Statista, 2025 estimates).
|
||||
- Key trends: Rise in AI-personalized workouts (e.g., 40% of users prefer apps with recommendations), gym digitization (70% of gyms adopting management software), and mobile check-in systems to reduce wait times.
|
||||
- Target Market:
|
||||
- Primary: Gym owners and trainers (B2B) seeking affordable tools to manage 50-500 clients.
|
||||
- Secondary: Fitness enthusiasts aged 18-45, with 60% smartphone penetration in fitness routines.
|
||||
- Initial Focus: Partnership gyms in mid-sized cities; pilot with 100-client gym represents a micro-market worth $10,000-20,000 annually in fees.
|
||||
- Market Opportunity: Only 30% of small gyms use integrated apps; our solution fills the gap for affordable, customizable platforms. In North Macedonia and surrounding regions, gym memberships grew 15% YoY, with digital adoption lagging.
|
||||
|
||||
**Competitive Analysis**:
|
||||
- Direct Competitors: GymMaster (management-focused, lacks AI), Mindbody (expensive for small gyms), Fitbit/Strava (user-focused, no admin tools).
|
||||
- Indirect: Free apps like MyFitnessPal (no gym integration) or enterprise solutions like ClubReady.
|
||||
- Differentiation: Open-source friendly DB abstraction for scalability, mandatory user adoption via gym partnerships, phased AI rollout. Strengths: Low entry cost ($50/month per gym), cross-platform mobile app. Weaknesses: New entrant; mitigate via pilot success stories.
|
||||
- SWOT:
|
||||
- Strengths: Tech stack efficiency, gym partnerships for instant users.
|
||||
- Weaknesses: Reliance on single pilot; scale DB early.
|
||||
- Opportunities: AI expansion, international gyms.
|
||||
- Threats: Data privacy regulations (GDPR compliance via Clerk), economic downturns affecting gym memberships.
|
||||
|
||||
# Organization and Management
|
||||
|
||||
**Team Structure**:
|
||||
- CEO/Founder: Oversees strategy and partnerships.
|
||||
- CTO: Leads development (NestJS admin, React Native app, DB abstraction).
|
||||
- Fitness Expert/Advisor: Collaborates with trainers for feature design.
|
||||
- Initial Team: 3-5 members (developers, marketer). Outsource UI/UX design if needed.
|
||||
- Advisors: Gym partner owner for industry insights; tech mentor for scalability.
|
||||
|
||||
**Operations**:
|
||||
- Development: Agile methodology; Phase 1 MVP in 3-6 months.
|
||||
- Tech Stack: NestJS (backend), React Native (mobile), Clerk (auth), SQLite (initial DB with abstraction for MySQL/PostgreSQL switches).
|
||||
- Compliance: Ensure HIPAA/GDPR for health data; secure payments via Stripe integration.
|
||||
|
||||
# Products and Services
|
||||
|
||||
**Core Offerings**:
|
||||
- **Mobile App (React Native)**: User registration, fitness profile form (e.g., age, goals, medical history), check-in/check-out via QR/Geo-fencing, notifications for sessions/payments.
|
||||
- **Admin App (NestJS)**: Dashboard for attendance tracking, payment logs (manual input for dates), user profile access, trainer recommendations prep.
|
||||
- **Notifications System**: Push alerts for overdue payments, workout reminders.
|
||||
|
||||
**Phase 1 Rollout**:
|
||||
- User Flow: Download app → Create account (Clerk) → Fill profile → Data syncs to DB → Accessible in admin app.
|
||||
- Gym Integration: Mandatory for 100 clients; admin inputs payment data.
|
||||
- Pricing: Free for users; $50/month per gym for admin access.
|
||||
|
||||
**Future Phases**:
|
||||
- Phase 2: Gym/trainer selection; AI backend (NestJS/Go) for automated recommendations based on profiles (e.g., workout plans via ML models).
|
||||
- Add-ons: Wearable integration (e.g., Apple Health), premium AI features ($4.99/user/month).
|
||||
- Scalability: DB switch for performance; multi-gym support.
|
||||
|
||||
**Development Timeline**:
|
||||
- Month 1-2: Core auth, profile, check-in features.
|
||||
- Month 3: Admin dashboard, notifications.
|
||||
- Month 4: Testing with pilot gym.
|
||||
- Ongoing: AI R&D.
|
||||
|
||||
# Marketing and Sales Strategy
|
||||
|
||||
**Target Audience**: Gym owners (via partnerships), users (via gym mandates and app stores).
|
||||
|
||||
**Marketing Plan**:
|
||||
- Digital: SEO for "gym management app", social media ads on LinkedIn/Facebook targeting fitness pros.
|
||||
- Partnerships: Leverage pilot gym for testimonials; offer free trials to 5 more gyms.
|
||||
- Content: Blog on fitness tech trends; app store optimization.
|
||||
- Budget: $10,000 Year 1 (ads, influencers).
|
||||
|
||||
**Sales Strategy**:
|
||||
- B2B: Direct outreach to gyms; demo sessions.
|
||||
- B2C: Organic via gym requirements; upsell premium features.
|
||||
- Growth: Aim for 10 gyms by end of Year 1, 50 by Year 2.
|
||||
|
||||
**Metrics**: Track app downloads (target 500 in Year 1), retention (80% via notifications), churn.
|
||||
|
||||
# Funding Request
|
||||
|
||||
Seeking $200,000 seed funding:
|
||||
- $100,000: Development (salaries, tools).
|
||||
- $50,000: Marketing and partnerships.
|
||||
- $30,000: Operations (servers, legal).
|
||||
- $20,000: Contingency.
|
||||
Equity offer: 10-15% stake. Bootstrap initially via pilot revenue.
|
||||
|
||||
# Financial Projections
|
||||
|
||||
**Assumptions**: Pilot gym pays $600/year; scale to 10 gyms Year 1. User premium: 20% adoption at $50/year average.
|
||||
|
||||
**Year 1**:
|
||||
- Revenue: $50,000 (gyms $6,000, users $44,000).
|
||||
- Expenses: $150,000 (dev $80k, marketing $30k, ops $40k).
|
||||
- Net: -$100,000 (funded by seed).
|
||||
|
||||
**Year 2**:
|
||||
- Revenue: $300,000 (50 gyms, 10k users).
|
||||
- Expenses: $200,000.
|
||||
- Net: $100,000.
|
||||
|
||||
**Year 3**: Break even at $500,000 revenue; profitability.
|
||||
|
||||
**Break-Even Analysis**: 20 gyms + 5k premium users.
|
||||
|
||||
Use tools like Excel for detailed projections (we can iterate on this).
|
||||
|
||||
# Appendix
|
||||
|
||||
- Tech Specs: Detailed wireframes, API docs.
|
||||
- Market Research Sources: Statista, Grand View Research.
|
||||
- Legal: Sample partnership agreements.
|
||||
|
||||
This is a solid starting outline. We can refine sections (e.g., add real financial models via code_execution tool if you provide data) or expand on specifics like AI implementation. What part do you want to dive deeper into first?
|
||||
|
||||
---and
|
||||
|
||||
|
||||
we are seeking 30k from investor for 10% stake, we are two developers and are planing to build the app in 24 months. provide datailed plan with 3 month circles. project first stable income after 15 months. developers get 5 p/h.
|
||||
provide detailed break of goals and activities
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
## flow
|
||||
|
||||
# 1
|
||||
- when we sign agreament with the gym they get admin profile
|
||||
-> they send invite to trainers [and posibly end users]
|
||||
-> assign users to trainers
|
||||
# 2
|
||||
- user downloads the app or visit website
|
||||
-> they can select plan
|
||||
-> they can select gym
|
||||
-> then select trainer
|
||||
Reference in new issue
Block a user