basic stracture and auth
in memory db :)
This commit is contained in:
commit
3a554ba434
178
.gitignore
vendored
Normal file
178
.gitignore
vendored
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Production builds
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Temporary folders
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
web-build/
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# React Native
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
# Linux
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Watchman
|
||||||
|
.watchmanconfig
|
||||||
|
|
||||||
|
# Flipper
|
||||||
|
ios/Pods/Flipper
|
||||||
|
ios/Pods/Flipper-Folly
|
||||||
|
ios/Pods/Flipper-Glog
|
||||||
|
ios/Pods/Flipper-PeerTalk
|
||||||
|
ios/Pods/Flipper-RSocket
|
||||||
|
|
||||||
|
# Ruby / CocoaPods
|
||||||
|
/ios/Pods/
|
||||||
|
/vendor/bundle/
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
*/fastlane/report.xml
|
||||||
|
*/fastlane/Preview.html
|
||||||
|
*/fastlane/screenshots
|
||||||
|
*/fastlane/test_output
|
||||||
|
|
||||||
|
# Bundle artifact
|
||||||
|
*.jsbundle
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
/ios/Pods/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo-shared/
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Storybook build outputs
|
||||||
|
storybook-static
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
22
AGENTS.md
Normal file
22
AGENTS.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
FitAI is a fitness management solution with admin web app and React Native mobile app for client management, payments, attendance tracking, and notifications.
|
||||||
|
|
||||||
|
## Build/Test Commands
|
||||||
|
This project is in early prototype stage - no build system configured yet.
|
||||||
|
- When implementing: expect standard npm/yarn commands (npm run dev, npm run build, npm test)
|
||||||
|
- For single tests: use npm test -- --testNamePattern="test name" or npm test path/to/test.test.js
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
- Use TypeScript for type safety
|
||||||
|
- Follow React/React Native conventions
|
||||||
|
- Use camelCase for variables and functions
|
||||||
|
- Use PascalCase for components
|
||||||
|
- Import order: external libraries → internal modules → relative imports
|
||||||
|
- Implement proper error handling with try/catch blocks
|
||||||
|
- Use semantic HTML and accessible components
|
||||||
|
- Follow mobile-first responsive design principles
|
||||||
|
- Implement proper state management (Context API or Redux)
|
||||||
|
- Use environment variables for configuration
|
||||||
|
- Write comprehensive tests for critical functionality
|
||||||
214
PROGRESS.md
Normal file
214
PROGRESS.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# FitAI Development Progress
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### ✅ Completed Features
|
||||||
|
|
||||||
|
#### 1. Project Structure
|
||||||
|
- Monorepo setup with separate admin and mobile apps
|
||||||
|
- Admin: Next.js 14 + TypeScript + Tailwind CSS
|
||||||
|
- Mobile: React Native + Expo + TypeScript
|
||||||
|
- Shared utilities and types package
|
||||||
|
|
||||||
|
#### 2. Authentication System
|
||||||
|
- **User Registration**: Mobile app registration form with validation
|
||||||
|
- **User Login**: Secure login with password hashing (bcryptjs)
|
||||||
|
- **Data Storage**: In-memory database (demo) with user and client schemas
|
||||||
|
- **Admin Access**: User management dashboard with role-based filtering
|
||||||
|
|
||||||
|
#### 3. API Endpoints
|
||||||
|
- `POST /api/auth/register` - User registration with client creation
|
||||||
|
- `POST /api/auth/login` - User authentication
|
||||||
|
- `GET /api/users` - User listing with role filtering
|
||||||
|
- All endpoints include proper error handling and validation
|
||||||
|
|
||||||
|
#### 4. Mobile App Features
|
||||||
|
- Registration screen with form validation
|
||||||
|
- Login screen with secure credential storage
|
||||||
|
- Authentication context with protected routes
|
||||||
|
- Tab navigation (Home, Profile, Attendance)
|
||||||
|
- User profile display with logout functionality
|
||||||
|
|
||||||
|
#### 5. Admin Dashboard Features
|
||||||
|
- User management interface
|
||||||
|
- Role-based filtering (All, Clients, Trainers, Admins)
|
||||||
|
- User cards showing membership details
|
||||||
|
- Responsive design with Tailwind CSS
|
||||||
|
- Real-time user data display
|
||||||
|
|
||||||
|
## Current User Flow
|
||||||
|
|
||||||
|
### Registration Flow
|
||||||
|
1. User opens mobile app → Registration screen
|
||||||
|
2. Fills form (email, password, name, phone)
|
||||||
|
3. Submits → API creates user + client record
|
||||||
|
4. Success → Redirect to login screen
|
||||||
|
5. User can login and access protected tabs
|
||||||
|
|
||||||
|
### Admin Management Flow
|
||||||
|
1. Admin opens dashboard → User management section
|
||||||
|
2. Views all users with role filtering
|
||||||
|
3. Sees user details: membership type, status, join date
|
||||||
|
4. Can filter by user role for easier management
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Database Schema (In-Memory)
|
||||||
|
```typescript
|
||||||
|
User: {
|
||||||
|
id, email, firstName, lastName, password, role, phone, createdAt
|
||||||
|
}
|
||||||
|
Client: {
|
||||||
|
id, userId, membershipType, membershipStatus, joinDate, lastVisit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Password hashing with bcryptjs
|
||||||
|
- JWT-like session storage in mobile (SecureStore)
|
||||||
|
- Protected routes with authentication context
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
- RESTful endpoints with proper HTTP status codes
|
||||||
|
- Input validation and error handling
|
||||||
|
- TypeScript interfaces for type safety
|
||||||
|
|
||||||
|
## 🚧 Next Steps
|
||||||
|
|
||||||
|
### Phase 1: Core Features (Priority: High)
|
||||||
|
|
||||||
|
#### 1.1 Database Implementation
|
||||||
|
- [ ] Replace in-memory storage with persistent database
|
||||||
|
- [ ] Set up PostgreSQL or MongoDB with Drizzle ORM
|
||||||
|
- [ ] Create database migration scripts
|
||||||
|
- [ ] Add database connection pooling
|
||||||
|
|
||||||
|
#### 1.2 Enhanced User Management
|
||||||
|
- [ ] User CRUD operations (edit, delete, deactivate)
|
||||||
|
- [ ] Bulk user operations
|
||||||
|
- [ ] User search and pagination
|
||||||
|
- [ ] User activity logs
|
||||||
|
|
||||||
|
#### 1.3 Payment System
|
||||||
|
- [ ] Payment schema and API endpoints
|
||||||
|
- [ ] Payment status tracking
|
||||||
|
- [ ] Payment history for clients
|
||||||
|
- [ ] Automated payment reminders
|
||||||
|
|
||||||
|
#### 1.4 Attendance Tracking
|
||||||
|
- [ ] Check-in/check-out functionality
|
||||||
|
- [ ] QR code or NFC check-in
|
||||||
|
- [ ] Attendance analytics and reports
|
||||||
|
- [ ] Class scheduling integration
|
||||||
|
|
||||||
|
### Phase 2: Advanced Features (Priority: Medium)
|
||||||
|
|
||||||
|
#### 2.1 Notifications System
|
||||||
|
- [ ] Push notification setup
|
||||||
|
- [ ] Email notifications
|
||||||
|
- [ ] SMS notifications
|
||||||
|
- [ ] Notification preferences
|
||||||
|
|
||||||
|
#### 2.2 Enhanced Mobile Features
|
||||||
|
- [ ] Offline mode support
|
||||||
|
- [ ] Workout tracking
|
||||||
|
- [ ] Progress photos and measurements
|
||||||
|
- [ ] Goal setting and tracking
|
||||||
|
|
||||||
|
#### 2.3 Admin Analytics
|
||||||
|
- [ ] Dashboard metrics and KPIs
|
||||||
|
- [ ] Revenue tracking
|
||||||
|
- [ ] Member retention analytics
|
||||||
|
- [ ] Peak hours analysis
|
||||||
|
|
||||||
|
#### 2.4 Trainer Features
|
||||||
|
- [ ] Trainer-client assignment
|
||||||
|
- [ ] Workout plan creation
|
||||||
|
- [ ] Progress tracking tools
|
||||||
|
- [ ] Communication system
|
||||||
|
|
||||||
|
### Phase 3: AI Integration (Priority: Low)
|
||||||
|
|
||||||
|
#### 3.1 Fitness AI
|
||||||
|
- [ ] Workout recommendation engine
|
||||||
|
- [ ] Progress prediction algorithms
|
||||||
|
- [ ] Nutrition suggestions
|
||||||
|
- [ ] Injury risk assessment
|
||||||
|
|
||||||
|
#### 3.2 Business Intelligence
|
||||||
|
- [ ] Predictive analytics
|
||||||
|
- [ ] Churn prediction
|
||||||
|
- [ ] Revenue optimization
|
||||||
|
- [ ] Capacity planning
|
||||||
|
|
||||||
|
## Technical Debt & Improvements
|
||||||
|
|
||||||
|
### Immediate (Next Sprint)
|
||||||
|
- [ ] Add comprehensive error boundaries
|
||||||
|
- [ ] Implement proper logging system
|
||||||
|
- [ ] Add unit and integration tests
|
||||||
|
- [ ] Set up CI/CD pipeline
|
||||||
|
- [ ] Add input sanitization and security headers
|
||||||
|
|
||||||
|
### Medium Term
|
||||||
|
- [ ] Implement proper state management (Redux/Zustand)
|
||||||
|
- [ ] Add API rate limiting
|
||||||
|
- [ ] Implement caching strategy
|
||||||
|
- [ ] Add monitoring and alerting
|
||||||
|
- [ ] Performance optimization
|
||||||
|
|
||||||
|
### Long Term
|
||||||
|
- [ ] Microservices architecture
|
||||||
|
- [ ] Multi-tenant support
|
||||||
|
- [ ] Advanced security features
|
||||||
|
- [ ] Internationalization
|
||||||
|
- [ ] Progressive Web App (PWA)
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Code Standards
|
||||||
|
- TypeScript for type safety
|
||||||
|
- ESLint + Prettier for code formatting
|
||||||
|
- Conventional commits for version control
|
||||||
|
- Feature branch development workflow
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
- Unit tests for business logic
|
||||||
|
- Integration tests for API endpoints
|
||||||
|
- E2E tests for critical user flows
|
||||||
|
- Manual testing checklist for releases
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- Staging environment for testing
|
||||||
|
- Blue-green deployment strategy
|
||||||
|
- Database migration scripts
|
||||||
|
- Rollback procedures
|
||||||
|
|
||||||
|
## Current Limitations
|
||||||
|
|
||||||
|
1. **Data Persistence**: Using in-memory storage (demo only)
|
||||||
|
2. **Security**: Basic authentication, no advanced security features
|
||||||
|
3. **Scalability**: Single-instance deployment
|
||||||
|
4. **Testing**: No automated tests implemented
|
||||||
|
5. **Monitoring**: No logging or monitoring setup
|
||||||
|
6. **Performance**: No optimization or caching
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Technical Metrics
|
||||||
|
- [ ] API response time < 200ms
|
||||||
|
- [ ] Mobile app load time < 3s
|
||||||
|
- [ ] 99.9% uptime
|
||||||
|
- [ ] Zero critical security vulnerabilities
|
||||||
|
|
||||||
|
### Business Metrics
|
||||||
|
- [ ] User registration conversion rate > 80%
|
||||||
|
- [ ] Admin task completion time < 2min
|
||||||
|
- [ ] User retention rate > 90%
|
||||||
|
- [ ] System adoption rate > 95%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: November 7, 2025
|
||||||
|
**Version**: 1.0.0-alpha
|
||||||
|
**Next Review**: After Phase 1 completion
|
||||||
117
README.md
Normal file
117
README.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# FitAI
|
||||||
|
|
||||||
|
Integrated AI solution for fitness houses and their clients.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
fitai/
|
||||||
|
├── apps/
|
||||||
|
│ ├── admin/ # Next.js admin dashboard
|
||||||
|
│ └── mobile/ # React Native mobile app (Expo)
|
||||||
|
├── packages/
|
||||||
|
│ └── shared/ # Shared types and utilities
|
||||||
|
└── AGENTS.md # Development guidelines
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js >= 18.0.0
|
||||||
|
- npm >= 9.0.0
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
```bash
|
||||||
|
# Install root dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Install admin dependencies
|
||||||
|
cd apps/admin && npm install
|
||||||
|
|
||||||
|
# Install mobile dependencies
|
||||||
|
cd apps/mobile && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
# Start both apps together
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Or start individually:
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Admin Dashboard
|
||||||
|
- Client management
|
||||||
|
- Payment tracking
|
||||||
|
- Attendance monitoring
|
||||||
|
- Data visualization
|
||||||
|
|
||||||
|
### Mobile App
|
||||||
|
- Client profile management
|
||||||
|
- Attendance tracking
|
||||||
|
- Payment notifications
|
||||||
|
- Fitness progress tracking
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Admin**: Next.js 14, React 18, TypeScript, Tailwind CSS
|
||||||
|
- **Mobile**: React Native, Expo Router, TypeScript
|
||||||
|
- **Shared**: TypeScript, Zod for validation
|
||||||
|
- **State Management**: React Query, React Hook Form
|
||||||
|
- **Data Grid**: AG Grid for advanced user management
|
||||||
|
- **Charts**: AG Charts for analytics and visualization
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Admin Dashboard
|
||||||
|
- **User Management**: Advanced AG Grid with filtering, sorting, pagination
|
||||||
|
- **Analytics**: Interactive charts (line, pie, bar) with AG Charts
|
||||||
|
- **Data Export**: CSV export functionality
|
||||||
|
- **Real-time Updates**: Live user data synchronization
|
||||||
|
- **Responsive Design**: Mobile-first responsive interface
|
||||||
|
|
||||||
|
### Mobile App
|
||||||
|
- **Authentication**: Secure registration and login
|
||||||
|
- **User Profile**: Personal information management
|
||||||
|
- **Protected Routes**: Authentication-based navigation
|
||||||
|
- **Secure Storage**: Encrypted credential storage
|
||||||
|
|
||||||
|
### Data Visualization
|
||||||
|
- **User Growth**: Line chart showing user acquisition over time
|
||||||
|
- **Membership Distribution**: Pie chart of membership types
|
||||||
|
- **Revenue Analytics**: Bar chart for monthly revenue tracking
|
||||||
|
- **Key Metrics**: Real-time KPI dashboard
|
||||||
16
apps/admin/.eslintrc.json
Normal file
16
apps/admin/.eslintrc.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"no-var": "error"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["node_modules", ".next", "dist"]
|
||||||
|
}
|
||||||
45
apps/admin/.gitignore
vendored
Normal file
45
apps/admin/.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Admin app specific
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Mobile app specific
|
||||||
|
.expo/
|
||||||
|
.expo-shared/
|
||||||
|
web-build/
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
ios/Pods/
|
||||||
|
*.xcode.env
|
||||||
|
ios/Pods/Flipper*
|
||||||
|
ios/Pods/Flipper-Folly
|
||||||
|
ios/Pods/Flipper-Glog
|
||||||
|
ios/Pods/Flipper-PeerTalk
|
||||||
|
ios/Pods/Flipper-RSocket
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/app/build/
|
||||||
|
android/gradle/
|
||||||
|
android/gradlew
|
||||||
|
android/gradlew.bat
|
||||||
|
android/local.properties
|
||||||
|
android/*.iml
|
||||||
|
android/.gradle/
|
||||||
|
android/app/release/
|
||||||
|
android/app/debug/
|
||||||
|
android/app/build/generated/
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
*/fastlane/report.xml
|
||||||
|
*/fastlane/Preview.html
|
||||||
|
*/fastlane/screenshots
|
||||||
|
*/fastlane/test_output
|
||||||
|
|
||||||
|
# Bundle artifact
|
||||||
|
*.jsbundle
|
||||||
15
apps/admin/jest.config.js
Normal file
15
apps/admin/jest.config.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
moduleNameMapping: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
testMatch: [
|
||||||
|
'**/__tests__/**/*.(ts|tsx|js)',
|
||||||
|
'**/*.(test|spec).(ts|tsx|js)',
|
||||||
|
],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(ts|tsx)$': 'ts-jest',
|
||||||
|
},
|
||||||
|
}
|
||||||
1
apps/admin/jest.setup.js
Normal file
1
apps/admin/jest.setup.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
5
apps/admin/next-env.d.ts
vendored
Normal file
5
apps/admin/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
8
apps/admin/next.config.js
Normal file
8
apps/admin/next.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
10176
apps/admin/package-lock.json
generated
Normal file
10176
apps/admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
apps/admin/package.json
Normal file
46
apps/admin/package.json
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@fitai/admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fitai/shared": "file:../../packages/shared",
|
||||||
|
"@hookform/resolvers": "^3.3.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"next": "^14.0.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"react-hook-form": "^7.47.0",
|
||||||
|
"recharts": "^2.8.0",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"zod": "^3.22.0",
|
||||||
|
"ag-grid-community": "^32.0.0",
|
||||||
|
"ag-grid-react": "^32.0.0",
|
||||||
|
"ag-charts-community": "^9.0.0",
|
||||||
|
"ag-charts-react": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.1.0",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "18.3.26",
|
||||||
|
"@types/react-dom": "^18.0.0",
|
||||||
|
"eslint": "^8.45.0",
|
||||||
|
"eslint-config-next": "^14.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/admin/postcss.config.js
Normal file
6
apps/admin/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
11
apps/admin/src/app/analytics/page.tsx
Normal file
11
apps/admin/src/app/analytics/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard'
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<AnalyticsDashboard />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
apps/admin/src/app/api/auth/login/route.ts
Normal file
47
apps/admin/src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { users } from '../../../../lib/database'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, password } = await request.json()
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users.find(u => u.email === email)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password: _, ...userWithoutPassword } = user
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Login successful',
|
||||||
|
user: userWithoutPassword
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/admin/src/app/api/auth/register/route.ts
Normal file
73
apps/admin/src/app/api/auth/register/route.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { users, clients } from '../../../../lib/database'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, password, firstName, lastName, phone } = await request.json()
|
||||||
|
|
||||||
|
if (!email || !password || !firstName || !lastName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = users.find(u => u.email === email)
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'User already exists' },
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10)
|
||||||
|
const userId = Math.random().toString(36).substr(2, 9)
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password: hashedPassword,
|
||||||
|
phone,
|
||||||
|
role: 'client',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
users.push(newUser)
|
||||||
|
|
||||||
|
const newClient = {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
userId,
|
||||||
|
membershipType: 'basic',
|
||||||
|
membershipStatus: 'active',
|
||||||
|
joinDate: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.push(newClient)
|
||||||
|
|
||||||
|
const { password: _, ...userWithoutPassword } = newUser
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: 'User registered successfully',
|
||||||
|
user: { ...userWithoutPassword, client: newClient }
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const usersWithoutPassword = users.map(({ password: _, ...user }) => user)
|
||||||
|
return NextResponse.json({ users: usersWithoutPassword })
|
||||||
|
}
|
||||||
28
apps/admin/src/app/api/users/route.ts
Normal file
28
apps/admin/src/app/api/users/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { users, clients } from '../../../lib/database'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const role = searchParams.get('role')
|
||||||
|
|
||||||
|
let filteredUsers = users
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
filteredUsers = users.filter(user => user.role === role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersWithClients = filteredUsers.map(({ password: _, ...user }) => {
|
||||||
|
const client = clients.find(c => c.userId === user.id)
|
||||||
|
return { ...user, client }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ users: usersWithClients })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get users error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/admin/src/app/globals.css
Normal file
3
apps/admin/src/app/globals.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
22
apps/admin/src/app/layout.tsx
Normal file
22
apps/admin/src/app/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'FitAI Admin',
|
||||||
|
description: 'Fitness management admin dashboard',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
apps/admin/src/app/page.tsx
Normal file
64
apps/admin/src/app/page.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { UserManagement } from '@/components/users/UserManagement'
|
||||||
|
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900">
|
||||||
|
FitAI Admin Dashboard
|
||||||
|
</h1>
|
||||||
|
<nav className="flex gap-4">
|
||||||
|
<Link
|
||||||
|
href="/users"
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
User Management
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/analytics"
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Analytics
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Client Management</h2>
|
||||||
|
<p className="text-gray-600">Manage fitness clients and their profiles</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Payment Tracking</h2>
|
||||||
|
<p className="text-gray-600">Monitor payments and subscriptions</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Attendance</h2>
|
||||||
|
<p className="text-gray-600">Track client attendance and habits</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Recent User Activity</h2>
|
||||||
|
<div className="h-96">
|
||||||
|
<UserManagement />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Quick Analytics</h2>
|
||||||
|
<div className="h-96">
|
||||||
|
<AnalyticsDashboard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
apps/admin/src/app/users/page.tsx
Normal file
11
apps/admin/src/app/users/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { UserManagement } from '@/components/users/UserManagement'
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<UserManagement />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
apps/admin/src/components/analytics/AnalyticsDashboard.tsx
Normal file
140
apps/admin/src/components/analytics/AnalyticsDashboard.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { UserGrowthChart } from '@/components/charts/UserGrowthChart'
|
||||||
|
import { MembershipDistributionChart } from '@/components/charts/MembershipDistributionChart'
|
||||||
|
import { RevenueChart } from '@/components/charts/RevenueChart'
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card'
|
||||||
|
|
||||||
|
interface ChartData {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsDashboard() {
|
||||||
|
const [userGrowthData, setUserGrowthData] = useState<ChartData[]>([])
|
||||||
|
const [membershipData, setMembershipData] = useState<ChartData[]>([])
|
||||||
|
const [revenueData, setRevenueData] = useState<ChartData[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAnalyticsData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchAnalyticsData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// Mock data for demonstration - replace with real API calls
|
||||||
|
const mockUserGrowth = [
|
||||||
|
{ label: 'Jan', value: 45 },
|
||||||
|
{ label: 'Feb', value: 52 },
|
||||||
|
{ label: 'Mar', value: 61 },
|
||||||
|
{ label: 'Apr', value: 58 },
|
||||||
|
{ label: 'May', value: 67 },
|
||||||
|
{ label: 'Jun', value: 74 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockMembershipData = [
|
||||||
|
{ label: 'Basic', value: 45, color: '#6b7280' },
|
||||||
|
{ label: 'Premium', value: 28, color: '#3b82f6' },
|
||||||
|
{ label: 'VIP', value: 12, color: '#f59e0b' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockRevenueData = [
|
||||||
|
{ label: 'Jan', value: 12500, color: '#10b981' },
|
||||||
|
{ label: 'Feb', value: 14200, color: '#10b981' },
|
||||||
|
{ label: 'Mar', value: 16800, color: '#10b981' },
|
||||||
|
{ label: 'Apr', value: 15900, color: '#10b981' },
|
||||||
|
{ label: 'May', value: 18200, color: '#10b981' },
|
||||||
|
{ label: 'Jun', value: 19400, color: '#10b981' },
|
||||||
|
]
|
||||||
|
|
||||||
|
setUserGrowthData(mockUserGrowth)
|
||||||
|
setMembershipData(mockMembershipData)
|
||||||
|
setRevenueData(mockRevenueData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch analytics data:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUsers = userGrowthData.length > 0 ? userGrowthData[userGrowthData.length - 1].value : 0
|
||||||
|
const totalRevenue = revenueData.reduce((sum, item) => sum + item.value, 0)
|
||||||
|
const activeMembers = membershipData.reduce((sum, item) => sum + item.value, 0)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="text-lg">Loading analytics...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-2xl font-bold">Analytics Dashboard</h2>
|
||||||
|
|
||||||
|
{/* Key Metrics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-blue-600">{totalUsers}</div>
|
||||||
|
<div className="text-gray-600">Total Users</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-green-600">${totalRevenue.toLocaleString()}</div>
|
||||||
|
<div className="text-gray-600">Total Revenue</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold text-purple-600">{activeMembers}</div>
|
||||||
|
<div className="text-gray-600">Active Members</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">User Growth</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UserGrowthChart data={userGrowthData} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">Membership Distribution</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MembershipDistributionChart data={membershipData} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">Monthly Revenue</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RevenueChart data={revenueData} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { AgChartsReact } from 'ag-charts-react'
|
||||||
|
import { AgChartOptions } from 'ag-charts-community'
|
||||||
|
|
||||||
|
interface PieData {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MembershipDistributionChartProps {
|
||||||
|
data: PieData[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembershipDistributionChart({ data, title = 'Membership Distribution' }: MembershipDistributionChartProps) {
|
||||||
|
const chartOptions: AgChartOptions = useMemo(() => ({
|
||||||
|
title: {
|
||||||
|
text: title,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
calloutLabelKey: 'label',
|
||||||
|
angleKey: 'value',
|
||||||
|
sectorLabelKey: 'label',
|
||||||
|
fills: data.map(item => item.color || '#3b82f6'),
|
||||||
|
strokes: ['#ffffff'],
|
||||||
|
strokeWidth: 2,
|
||||||
|
calloutLabel: {
|
||||||
|
enabled: true,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
sectorLabel: {
|
||||||
|
enabled: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#ffffff',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const percentage = ((params.datum.value / data.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1)
|
||||||
|
return `${params.datum.label}: ${percentage}%`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
highlightStyle: {
|
||||||
|
item: {
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
stroke: '#000000',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
renderer: (params: any) => {
|
||||||
|
const percentage = ((params.datum.value / data.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1)
|
||||||
|
return `<div class="bg-white p-2 rounded shadow-lg border">
|
||||||
|
<div class="font-bold">${params.datum.label}</div>
|
||||||
|
<div class="text-sm">Count: ${params.datum.value}</div>
|
||||||
|
<div class="text-sm">Percentage: ${percentage}%</div>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legend: {
|
||||||
|
enabled: true,
|
||||||
|
position: 'right',
|
||||||
|
fontSize: 12,
|
||||||
|
marker: {
|
||||||
|
shape: 'square',
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
}), [data, title])
|
||||||
|
|
||||||
|
return <AgChartsReact options={chartOptions} />
|
||||||
|
}
|
||||||
99
apps/admin/src/components/charts/RevenueChart.tsx
Normal file
99
apps/admin/src/components/charts/RevenueChart.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { AgChartsReact } from 'ag-charts-react'
|
||||||
|
import { AgChartOptions } from 'ag-charts-community'
|
||||||
|
|
||||||
|
interface BarData {
|
||||||
|
category: string
|
||||||
|
value: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RevenueChartProps {
|
||||||
|
data: BarData[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevenueChart({ data, title = 'Monthly Revenue' }: RevenueChartProps) {
|
||||||
|
const chartOptions: AgChartOptions = useMemo(() => ({
|
||||||
|
title: {
|
||||||
|
text: title,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
xKey: 'category',
|
||||||
|
yKey: 'value',
|
||||||
|
fills: data.map(item => item.color || '#10b981'),
|
||||||
|
strokes: ['#ffffff'],
|
||||||
|
strokeWidth: 2,
|
||||||
|
cornerRadius: 4,
|
||||||
|
highlightStyle: {
|
||||||
|
item: {
|
||||||
|
fill: '#059669',
|
||||||
|
stroke: '#ffffff',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
enabled: true,
|
||||||
|
position: 'top',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#374151',
|
||||||
|
formatter: (params: any) => `$${params.value.toLocaleString()}`,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
renderer: (params: any) => {
|
||||||
|
return `<div class="bg-white p-2 rounded shadow-lg border">
|
||||||
|
<div class="font-bold">${params.datum.category}</div>
|
||||||
|
<div class="text-sm">Revenue: $${params.datum.value.toLocaleString()}</div>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
position: 'bottom',
|
||||||
|
title: {
|
||||||
|
text: 'Month',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 12,
|
||||||
|
rotation: 45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
position: 'left',
|
||||||
|
title: {
|
||||||
|
text: 'Revenue ($)',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 12,
|
||||||
|
formatter: (params: any) => `$${params.value.toLocaleString()}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legend: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 60,
|
||||||
|
left: 80,
|
||||||
|
},
|
||||||
|
}), [data, title])
|
||||||
|
|
||||||
|
return <AgChartsReact options={chartOptions} />
|
||||||
|
}
|
||||||
78
apps/admin/src/components/charts/UserGrowthChart.tsx
Normal file
78
apps/admin/src/components/charts/UserGrowthChart.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { AgChartsReact } from 'ag-charts-react'
|
||||||
|
import { AgChartOptions } from 'ag-charts-community'
|
||||||
|
|
||||||
|
interface ChartData {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserGrowthChartProps {
|
||||||
|
data: ChartData[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserGrowthChart({ data, title = 'User Growth' }: UserGrowthChartProps) {
|
||||||
|
const chartOptions: AgChartOptions = useMemo(() => ({
|
||||||
|
title: {
|
||||||
|
text: title,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
xKey: 'label',
|
||||||
|
yKey: 'value',
|
||||||
|
stroke: '#3b82f6',
|
||||||
|
strokeWidth: 3,
|
||||||
|
marker: {
|
||||||
|
size: 6,
|
||||||
|
fill: '#3b82f6',
|
||||||
|
stroke: '#ffffff',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
highlightStyle: {
|
||||||
|
item: {
|
||||||
|
fill: '#1d4ed8',
|
||||||
|
stroke: '#ffffff',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
position: 'bottom',
|
||||||
|
title: {
|
||||||
|
text: 'Time Period',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
position: 'left',
|
||||||
|
title: {
|
||||||
|
text: 'Number of Users',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legend: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
}), [data, title])
|
||||||
|
|
||||||
|
return <AgChartsReact options={chartOptions} />
|
||||||
|
}
|
||||||
23
apps/admin/src/components/ui/Button.tsx
Normal file
23
apps/admin/src/components/ui/Button.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary'
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant = 'primary', children, className = '', ...props }: ButtonProps) {
|
||||||
|
const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors'
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||||
|
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
apps/admin/src/components/ui/Card.tsx
Normal file
30
apps/admin/src/components/ui/Card.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = '' }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ children, className = '' }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ children, className = '' }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
167
apps/admin/src/components/users/UserGrid.tsx
Normal file
167
apps/admin/src/components/users/UserGrid.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { AgGridReact } from 'ag-grid-react'
|
||||||
|
import { ColDef } from 'ag-grid-community'
|
||||||
|
import 'ag-grid-community/styles/ag-grid.css'
|
||||||
|
import 'ag-grid-community/styles/ag-theme-alpine.css'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
role: string
|
||||||
|
phone?: string
|
||||||
|
createdAt: Date
|
||||||
|
client?: {
|
||||||
|
id: string
|
||||||
|
membershipType: string
|
||||||
|
membershipStatus: string
|
||||||
|
joinDate: Date
|
||||||
|
lastVisit?: Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserGridProps {
|
||||||
|
users: User[]
|
||||||
|
onUserSelect?: (user: User) => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserGrid({ users, onUserSelect, loading = false }: UserGridProps) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||||
|
|
||||||
|
const columnDefs: ColDef<User>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
headerName: 'Name',
|
||||||
|
valueGetter: (params) => `${params.data?.firstName} ${params.data?.lastName}`,
|
||||||
|
filter: 'agTextColumnFilter',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: 'Email',
|
||||||
|
field: 'email',
|
||||||
|
filter: 'agTextColumnFilter',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: 'Role',
|
||||||
|
field: 'role',
|
||||||
|
filter: 'agSetColumnFilter',
|
||||||
|
sortable: true,
|
||||||
|
cellRenderer: (params: any) => {
|
||||||
|
const roleColors = {
|
||||||
|
admin: 'bg-purple-100 text-purple-800',
|
||||||
|
trainer: 'bg-blue-100 text-blue-800',
|
||||||
|
client: 'bg-green-100 text-green-800',
|
||||||
|
}
|
||||||
|
const colorClass = roleColors[params.value as keyof typeof roleColors] || 'bg-gray-100 text-gray-800'
|
||||||
|
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`
|
||||||
|
},
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: 'Phone',
|
||||||
|
field: 'phone',
|
||||||
|
filter: 'agTextColumnFilter',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: 'Membership',
|
||||||
|
valueGetter: (params) => params.data?.client?.membershipType || 'N/A',
|
||||||
|
filter: 'agSetColumnFilter',
|
||||||
|
sortable: true,
|
||||||
|
cellRenderer: (params: any) => {
|
||||||
|
if (!params.value || params.value === 'N/A') return 'N/A'
|
||||||
|
|
||||||
|
const membershipColors = {
|
||||||
|
vip: 'bg-yellow-100 text-yellow-800',
|
||||||
|
premium: 'bg-blue-100 text-blue-800',
|
||||||
|
basic: 'bg-gray-100 text-gray-800',
|
||||||
|
}
|
||||||
|
const colorClass = membershipColors[params.value as keyof typeof membershipColors] || 'bg-gray-100 text-gray-800'
|
||||||
|
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`
|
||||||
|
},
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: 'Status',
|
||||||
|
valueGetter: (params) => params.data?.client?.membershipStatus || 'N/A',
|
||||||
|
filter: 'agSetColumnFilter',
|
||||||
|
sortable: true,
|
||||||
|
cellRenderer: (params: any) => {
|
||||||
|
if (!params.value || params.value === 'N/A') return 'N/A'
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
active: 'bg-green-100 text-green-800',
|
||||||
|
inactive: 'bg-red-100 text-red-800',
|
||||||
|
suspended: 'bg-yellow-100 text-yellow-800',
|
||||||
|
}
|
||||||
|
const colorClass = statusColors[params.value as keyof typeof statusColors] || 'bg-gray-100 text-gray-800'
|
||||||
|
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`
|
||||||
|
},
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: 'Join Date',
|
||||||
|
valueGetter: (params) => params.data?.client?.joinDate || params.data?.createdAt,
|
||||||
|
filter: 'agDateColumnFilter',
|
||||||
|
sortable: true,
|
||||||
|
valueFormatter: (params: any) => formatDate(new Date(params.value)),
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: 'Last Visit',
|
||||||
|
valueGetter: (params) => params.data?.client?.lastVisit,
|
||||||
|
filter: 'agDateColumnFilter',
|
||||||
|
sortable: true,
|
||||||
|
valueFormatter: (params: any) => params.value ? formatDate(new Date(params.value)) : 'Never',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
], [])
|
||||||
|
|
||||||
|
const defaultColDef: ColDef = useMemo(() => ({
|
||||||
|
flex: 1,
|
||||||
|
resizable: true,
|
||||||
|
floatingFilter: true,
|
||||||
|
suppressMenu: true,
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const onSelectionChanged = () => {
|
||||||
|
const selectedNodes = gridRef.current?.api.getSelectedNodes()
|
||||||
|
if (selectedNodes?.length > 0) {
|
||||||
|
const user = selectedNodes[0].data
|
||||||
|
setSelectedUser(user)
|
||||||
|
onUserSelect?.(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridRef = React.useRef<AgGridReact<User>>(null)
|
||||||
|
|
||||||
|
const gridOptions = {
|
||||||
|
columnDefs,
|
||||||
|
defaultColDef,
|
||||||
|
rowData: users,
|
||||||
|
rowSelection: 'single',
|
||||||
|
onSelectionChanged,
|
||||||
|
enableRangeSelection: true,
|
||||||
|
enableCellTextSelection: true,
|
||||||
|
suppressRowClickSelection: false,
|
||||||
|
animateRows: true,
|
||||||
|
loading: loading,
|
||||||
|
pagination: true,
|
||||||
|
paginationPageSize: 20,
|
||||||
|
paginationPageSizeSelector: [10, 20, 50, 100],
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ag-theme-alpine" style={{ height: '600px', width: '100%' }}>
|
||||||
|
<AgGridReact<User> {...gridOptions} ref={gridRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
180
apps/admin/src/components/users/UserManagement.tsx
Normal file
180
apps/admin/src/components/users/UserManagement.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { UserGrid } from '@/components/users/UserGrid'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
role: string
|
||||||
|
phone?: string
|
||||||
|
createdAt: Date
|
||||||
|
client?: {
|
||||||
|
id: string
|
||||||
|
membershipType: string
|
||||||
|
membershipStatus: string
|
||||||
|
joinDate: Date
|
||||||
|
lastVisit?: Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagement() {
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const url = filter === 'all'
|
||||||
|
? '/api/users'
|
||||||
|
: `/api/users?role=${filter}`
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
const data = await response.json()
|
||||||
|
setUsers(data.users || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUserSelect = (user: User) => {
|
||||||
|
setSelectedUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const csvContent = [
|
||||||
|
['Name', 'Email', 'Role', 'Phone', 'Membership', 'Status', 'Join Date', 'Last Visit'],
|
||||||
|
...users.map(user => [
|
||||||
|
`${user.firstName} ${user.lastName}`,
|
||||||
|
user.email,
|
||||||
|
user.role,
|
||||||
|
user.phone || '',
|
||||||
|
user.client?.membershipType || '',
|
||||||
|
user.client?.membershipStatus || '',
|
||||||
|
user.client?.joinDate || user.createdAt,
|
||||||
|
user.client?.lastVisit || ''
|
||||||
|
])
|
||||||
|
].map(row => row.join(',')).join('\n')
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv' })
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `users_${new Date().toISOString().split('T')[0]}.csv`
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold">User Management</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={filter === 'all' ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
>
|
||||||
|
All Users
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === 'client' ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => setFilter('client')}
|
||||||
|
>
|
||||||
|
Clients
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === 'trainer' ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => setFilter('trainer')}
|
||||||
|
>
|
||||||
|
Trainers
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === 'admin' ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => setFilter('admin')}
|
||||||
|
>
|
||||||
|
Admins
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Showing {users.length} users
|
||||||
|
{selectedUser && (
|
||||||
|
<span className="ml-4 text-blue-600">
|
||||||
|
Selected: {selectedUser.firstName} {selectedUser.lastName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={handleRefresh}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={handleExport}>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<UserGrid
|
||||||
|
users={users}
|
||||||
|
onUserSelect={handleUserSelect}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedUser && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">User Details</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Basic Information</h4>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p><span className="font-medium">Name:</span> {selectedUser.firstName} {selectedUser.lastName}</p>
|
||||||
|
<p><span className="font-medium">Email:</span> {selectedUser.email}</p>
|
||||||
|
<p><span className="font-medium">Phone:</span> {selectedUser.phone || 'N/A'}</p>
|
||||||
|
<p><span className="font-medium">Role:</span> {selectedUser.role}</p>
|
||||||
|
<p><span className="font-medium">Joined:</span> {selectedUser.createdAt.toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedUser.client && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Client Information</h4>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p><span className="font-medium">Membership:</span> {selectedUser.client.membershipType}</p>
|
||||||
|
<p><span className="font-medium">Status:</span> {selectedUser.client.membershipStatus}</p>
|
||||||
|
<p><span className="font-medium">Member Since:</span> {selectedUser.client.joinDate.toLocaleDateString()}</p>
|
||||||
|
<p><span className="font-medium">Last Visit:</span> {selectedUser.client.lastVisit?.toLocaleDateString() || 'Never'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
apps/admin/src/lib/database.ts
Normal file
23
apps/admin/src/lib/database.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory database
|
||||||
|
export const users: User[] = []
|
||||||
|
export const clients: Client[] = []
|
||||||
26
apps/admin/src/lib/utils.ts
Normal file
26
apps/admin/src/lib/utils.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export const formatDate = (date: Date): string => {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatCurrency = (
|
||||||
|
amount: number,
|
||||||
|
currency: string = 'USD'
|
||||||
|
): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateDaysBetween = (startDate: Date, endDate: Date): number => {
|
||||||
|
const timeDiff = endDate.getTime() - startDate.getTime()
|
||||||
|
return Math.ceil(timeDiff / (1000 * 3600 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateId = (): string => {
|
||||||
|
return Math.random().toString(36).substr(2, 9)
|
||||||
|
}
|
||||||
21
apps/admin/tailwind.config.js
Normal file
21
apps/admin/tailwind.config.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
51
apps/admin/tsconfig.json
Normal file
51
apps/admin/tsconfig.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es6"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"@/components/*": [
|
||||||
|
"./src/components/*"
|
||||||
|
],
|
||||||
|
"@/lib/*": [
|
||||||
|
"./src/lib/*"
|
||||||
|
],
|
||||||
|
"@/types/*": [
|
||||||
|
"./src/types/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
apps/mobile/.eslintrc.js
Normal file
16
apps/mobile/.eslintrc.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: [
|
||||||
|
'@react-native-community',
|
||||||
|
'@typescript-eslint/recommended',
|
||||||
|
],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['@typescript-eslint'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': 'error',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
},
|
||||||
|
ignorePatterns: ['node_modules', 'dist', 'expo'],
|
||||||
|
}
|
||||||
44
apps/mobile/.gitignore
vendored
Normal file
44
apps/mobile/.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Mobile app specific
|
||||||
|
.expo/
|
||||||
|
.expo-shared/
|
||||||
|
web-build/
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
ios/Pods/
|
||||||
|
*.xcode.env
|
||||||
|
ios/Pods/Flipper*
|
||||||
|
ios/Pods/Flipper-Folly
|
||||||
|
ios/Pods/Flipper-Glog
|
||||||
|
ios/Pods/Flipper-PeerTalk
|
||||||
|
ios/Pods/Flipper-RSocket
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/app/build/
|
||||||
|
android/gradle/
|
||||||
|
android/gradlew
|
||||||
|
android/gradlew.bat
|
||||||
|
android/local.properties
|
||||||
|
android/*.iml
|
||||||
|
android/.gradle/
|
||||||
|
android/app/release/
|
||||||
|
android/app/debug/
|
||||||
|
android/app/build/generated/
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
*/fastlane/report.xml
|
||||||
|
*/fastlane/Preview.html
|
||||||
|
*/fastlane/screenshots
|
||||||
|
*/fastlane/test_output
|
||||||
|
|
||||||
|
# Bundle artifact
|
||||||
|
*.jsbundle
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
34
apps/mobile/app.json
Normal file
34
apps/mobile/app.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "FitAI",
|
||||||
|
"slug": "fitai",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router"
|
||||||
|
],
|
||||||
|
"scheme": "fitai"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/mobile/assets/adaptive-icon.png
Normal file
1
apps/mobile/assets/adaptive-icon.png
Normal file
@ -0,0 +1 @@
|
|||||||
|
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||||
1
apps/mobile/assets/favicon.png
Normal file
1
apps/mobile/assets/favicon.png
Normal file
@ -0,0 +1 @@
|
|||||||
|
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||||
1
apps/mobile/assets/icon.png
Normal file
1
apps/mobile/assets/icon.png
Normal file
@ -0,0 +1 @@
|
|||||||
|
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||||
1
apps/mobile/assets/splash.png
Normal file
1
apps/mobile/assets/splash.png
Normal file
@ -0,0 +1 @@
|
|||||||
|
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||||
6
apps/mobile/babel.config.js
Normal file
6
apps/mobile/babel.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function(api) {
|
||||||
|
api.cache(true)
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/mobile/jest.config.js
Normal file
11
apps/mobile/jest.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'react-native',
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
moduleNameMapping: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(jest-)?react-native|@react-native|expo|@expo|@react-navigation)',
|
||||||
|
],
|
||||||
|
}
|
||||||
11
apps/mobile/jest.setup.js
Normal file
11
apps/mobile/jest.setup.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'react-native-gesture-handler/jestSetup'
|
||||||
|
|
||||||
|
jest.mock('react-native-reanimated', () => {
|
||||||
|
const Reanimated = require('react-native-reanimated/mock')
|
||||||
|
Reanimated.default.call = () => {}
|
||||||
|
return Reanimated
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.mock('@expo/vector-icons', () => ({
|
||||||
|
Ionicons: 'Ionicons',
|
||||||
|
}))
|
||||||
12361
apps/mobile/package-lock.json
generated
Normal file
12361
apps/mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
apps/mobile/package.json
Normal file
49
apps/mobile/package.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "@fitai/mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"build": "expo build",
|
||||||
|
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@hookform/resolvers": "^3.3.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"ajv": "^8.12.0",
|
||||||
|
"ajv-keywords": "^5.1.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"expo": "~54.0.0",
|
||||||
|
"expo-camera": "~17.0.9",
|
||||||
|
"expo-linking": "~8.0.8",
|
||||||
|
"expo-notifications": "~0.32.12",
|
||||||
|
"expo-router": "~6.0.14",
|
||||||
|
"expo-secure-store": "~15.0.7",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-hook-form": "^7.47.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"zod": "^3.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.0",
|
||||||
|
"@types/react": "~19.1.10",
|
||||||
|
"@types/react-native": "^0.73.0",
|
||||||
|
"typescript": "^5.1.3",
|
||||||
|
"eslint": "^8.45.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"jest": "^29.2.1",
|
||||||
|
"@testing-library/react-native": "^12.4.0",
|
||||||
|
"react-test-renderer": "19.1.0",
|
||||||
|
"babel-preset-expo": "~54.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
36
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Tabs } from 'expo-router'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Home',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="home" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: 'Profile',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="person" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="attendance"
|
||||||
|
options={{
|
||||||
|
title: 'Attendance',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="calendar" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
apps/mobile/src/app/(tabs)/attendance.tsx
Normal file
28
apps/mobile/src/app/(tabs)/attendance.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { View, Text, StyleSheet } from 'react-native'
|
||||||
|
|
||||||
|
export default function AttendanceScreen() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Attendance</Text>
|
||||||
|
<Text style={styles.subtitle}>Track your gym visits</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
})
|
||||||
45
apps/mobile/src/app/(tabs)/index.tsx
Normal file
45
apps/mobile/src/app/(tabs)/index.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text, StyleSheet } from 'react-native'
|
||||||
|
import { useRequireAuth } from '@/hooks/useRequireAuth'
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const { user } = useRequireAuth()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Welcome back!</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.email}>{user.email}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
})
|
||||||
116
apps/mobile/src/app/(tabs)/profile.tsx
Normal file
116
apps/mobile/src/app/(tabs)/profile.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useRouter } from 'expo-router'
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Logout',
|
||||||
|
'Are you sure you want to logout?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Logout',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await logout()
|
||||||
|
router.replace('/login')
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to logout')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.profileCard}>
|
||||||
|
<Text style={styles.title}>Profile</Text>
|
||||||
|
<Text style={styles.name}>
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.email}>{user?.email}</Text>
|
||||||
|
{user?.phone && <Text style={styles.phone}>{user.phone}</Text>}
|
||||||
|
|
||||||
|
<View style={styles.roleBadge}>
|
||||||
|
<Text style={styles.roleText}>
|
||||||
|
{user?.role.charAt(0).toUpperCase() + user?.role.slice(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||||
|
<Text style={styles.logoutText}>Logout</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
profileCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
roleBadge: {
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
roleText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
logoutButton: {
|
||||||
|
backgroundColor: '#ef4444',
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
logoutText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
})
|
||||||
15
apps/mobile/src/app/_layout.tsx
Normal file
15
apps/mobile/src/app/_layout.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { AuthProvider } from '@/contexts/AuthContext'
|
||||||
|
import { Stack } from 'expo-router'
|
||||||
|
import { View, Text } from 'react-native'
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="register" options={{ headerShown: false }} />
|
||||||
|
</Stack>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
apps/mobile/src/app/login.tsx
Normal file
139
apps/mobile/src/app/login.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
|
||||||
|
import { useRouter } from 'expo-router'
|
||||||
|
import axios from 'axios'
|
||||||
|
import * as SecureStore from 'expo-secure-store'
|
||||||
|
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!formData.email || !formData.password) {
|
||||||
|
Alert.alert('Error', 'Please fill in all fields')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.LOGIN}`, formData)
|
||||||
|
|
||||||
|
if (response.data.user) {
|
||||||
|
await SecureStore.setItemAsync('user', JSON.stringify(response.data.user))
|
||||||
|
Alert.alert('Success', 'Login successful!', [
|
||||||
|
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('Error', error.response?.data?.error || 'Login failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Welcome Back</Text>
|
||||||
|
<Text style={styles.subtitle}>Login to your FitAI account</Text>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Email"
|
||||||
|
value={formData.email}
|
||||||
|
onChangeText={(text) => setFormData({ ...formData, email: text })}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Password"
|
||||||
|
value={formData.password}
|
||||||
|
onChangeText={(text) => setFormData({ ...formData, password: text })}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, loading && styles.buttonDisabled]}
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => router.push('/register')}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>
|
||||||
|
Don't have an account? Register
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
backgroundColor: '#9ca3af',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: '#3b82f6',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
})
|
||||||
164
apps/mobile/src/app/register.tsx
Normal file
164
apps/mobile/src/app/register.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
|
||||||
|
import { useRouter } from 'expo-router'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
phone: '',
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
|
||||||
|
Alert.alert('Error', 'Please fill in all required fields')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.REGISTER}`, formData)
|
||||||
|
|
||||||
|
if (response.status === 201) {
|
||||||
|
Alert.alert('Success', 'Registration successful! Please login.', [
|
||||||
|
{ text: 'OK', onPress: () => router.push('/login') }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('Error', error.response?.data?.error || 'Registration failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Create Account</Text>
|
||||||
|
<Text style={styles.subtitle}>Join FitAI today</Text>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="First Name"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
|
||||||
|
autoCapitalize="words"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Last Name"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
|
||||||
|
autoCapitalize="words"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Email"
|
||||||
|
value={formData.email}
|
||||||
|
onChangeText={(text) => setFormData({ ...formData, email: text })}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Phone (optional)"
|
||||||
|
value={formData.phone}
|
||||||
|
onChangeText={(text) => setFormData({ ...formData, phone: text })}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Password"
|
||||||
|
value={formData.password}
|
||||||
|
onChangeText={(text) => setFormData({ ...formData, password: text })}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, loading && styles.buttonDisabled]}
|
||||||
|
onPress={handleRegister}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{loading ? 'Creating Account...' : 'Create Account'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => router.push('/login')}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>
|
||||||
|
Already have an account? Login
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
backgroundColor: '#9ca3af',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: '#3b82f6',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
})
|
||||||
12
apps/mobile/src/config/api.ts
Normal file
12
apps/mobile/src/config/api.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export const API_BASE_URL = __DEV__
|
||||||
|
? 'http://192.168.0.100:3000'
|
||||||
|
: 'https://your-production-url.com'
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
AUTH: {
|
||||||
|
LOGIN: '/api/auth/login',
|
||||||
|
REGISTER: '/api/auth/register',
|
||||||
|
},
|
||||||
|
CLIENTS: '/api/clients',
|
||||||
|
USERS: '/api/users',
|
||||||
|
}
|
||||||
76
apps/mobile/src/contexts/AuthContext.tsx
Normal file
76
apps/mobile/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import * as SecureStore from 'expo-secure-store'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
role: string
|
||||||
|
phone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null
|
||||||
|
login: (user: User) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUser()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadUser = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await SecureStore.getItemAsync('user')
|
||||||
|
if (userData) {
|
||||||
|
setUser(JSON.parse(userData))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (userData: User) => {
|
||||||
|
try {
|
||||||
|
await SecureStore.setItemAsync('user', JSON.stringify(userData))
|
||||||
|
setUser(userData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save user:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await SecureStore.deleteItemAsync('user')
|
||||||
|
setUser(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to logout:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
16
apps/mobile/src/hooks/useRequireAuth.ts
Normal file
16
apps/mobile/src/hooks/useRequireAuth.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useRouter } from 'expo-router'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useRequireAuth() {
|
||||||
|
const { user, isLoading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
}, [user, isLoading, router])
|
||||||
|
|
||||||
|
return { user, isLoading }
|
||||||
|
}
|
||||||
38
apps/mobile/tsconfig.json
Normal file
38
apps/mobile/tsconfig.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-native",
|
||||||
|
"lib": [
|
||||||
|
"es2017"
|
||||||
|
],
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "esnext",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"@/components/*": [
|
||||||
|
"./src/components/*"
|
||||||
|
],
|
||||||
|
"@/lib/*": [
|
||||||
|
"./src/lib/*"
|
||||||
|
],
|
||||||
|
"@/types/*": [
|
||||||
|
"./src/types/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"babel.config.js",
|
||||||
|
"metro.config.js",
|
||||||
|
"jest.config.js"
|
||||||
|
],
|
||||||
|
"extends": "expo/tsconfig.base"
|
||||||
|
}
|
||||||
5
extras.md
Normal file
5
extras.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
## extras
|
||||||
|
|
||||||
|
- clerk auth [and probably payment]
|
||||||
|
- stripe
|
||||||
|
- ag grid and charts
|
||||||
2923
package-lock.json
generated
Normal file
2923
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "fitai",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Integrated AI solution for fitness houses and their clients",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:admin\" \"npm run dev:mobile\"",
|
||||||
|
"dev:admin": "cd apps/admin && npx next dev",
|
||||||
|
"dev:mobile": "cd apps/mobile && npx expo start",
|
||||||
|
"build": "npm run build:admin && npm run build:mobile",
|
||||||
|
"build:admin": "cd apps/admin && npx next build",
|
||||||
|
"build:mobile": "cd apps/mobile && npx expo build",
|
||||||
|
"test": "npm run test:admin && npm run test:mobile",
|
||||||
|
"test:admin": "cd apps/admin && npx jest",
|
||||||
|
"test:mobile": "cd apps/mobile && npx jest",
|
||||||
|
"lint": "npm run lint:admin && npm run lint:mobile",
|
||||||
|
"lint:admin": "cd apps/admin && npx eslint . --ext .js,.jsx,.ts,.tsx",
|
||||||
|
"lint:mobile": "cd apps/mobile && npx eslint . --ext .js,.jsx,.ts,.tsx",
|
||||||
|
"typecheck": "npm run typecheck:admin && npm run typecheck:mobile",
|
||||||
|
"typecheck:admin": "cd apps/admin && npx tsc --noEmit",
|
||||||
|
"typecheck:mobile": "cd apps/mobile && npx tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"eslint": "^8.45.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"npm": ">=9.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^16.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/database/drizzle.config.ts
Normal file
10
packages/database/drizzle.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
driver: 'better-sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: './fitai.db',
|
||||||
|
},
|
||||||
|
})
|
||||||
22
packages/database/package.json
Normal file
22
packages/database/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@fitai/database",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"db:push": "drizzle-kit push:sqlite",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"drizzle-orm": "^0.29.0",
|
||||||
|
"better-sqlite3": "^9.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"drizzle-kit": "^0.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/database/src/index.ts
Normal file
8
packages/database/src/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||||
|
import * as schema from './schema'
|
||||||
|
|
||||||
|
const sqlite = new Database('./fitai.db')
|
||||||
|
export const db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
|
export * from './schema'
|
||||||
72
packages/database/src/schema.ts
Normal file
72
packages/database/src/schema.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
firstName: text('first_name').notNull(),
|
||||||
|
lastName: text('last_name').notNull(),
|
||||||
|
password: text('password').notNull(),
|
||||||
|
role: text('role', { enum: ['admin', 'trainer', 'client'] }).notNull().default('client'),
|
||||||
|
phone: text('phone'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const clients = sqliteTable('clients', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
membershipType: text('membership_type', { enum: ['basic', 'premium', 'vip'] }).notNull().default('basic'),
|
||||||
|
membershipStatus: text('membership_status', { enum: ['active', 'inactive', 'suspended'] }).notNull().default('active'),
|
||||||
|
joinDate: integer('join_date', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
lastVisit: integer('last_visit', { mode: 'timestamp' }),
|
||||||
|
emergencyContactName: text('emergency_contact_name'),
|
||||||
|
emergencyContactPhone: text('emergency_contact_phone'),
|
||||||
|
emergencyContactRelationship: text('emergency_contact_relationship'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const payments = sqliteTable('payments', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
clientId: text('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
amount: real('amount').notNull(),
|
||||||
|
currency: text('currency').notNull().default('USD'),
|
||||||
|
status: text('status', { enum: ['pending', 'completed', 'failed', 'refunded'] }).notNull().default('pending'),
|
||||||
|
paymentMethod: text('payment_method', { enum: ['cash', 'card', 'bank_transfer'] }).notNull(),
|
||||||
|
dueDate: integer('due_date', { mode: 'timestamp' }).notNull(),
|
||||||
|
paidAt: integer('paid_at', { mode: 'timestamp' }),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const attendance = sqliteTable('attendance', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
clientId: text('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
checkInTime: integer('check_in_time', { mode: 'timestamp' }).notNull(),
|
||||||
|
checkOutTime: integer('check_out_time', { mode: 'timestamp' }),
|
||||||
|
type: text('type', { enum: ['gym', 'class', 'personal_training'] }).notNull().default('gym'),
|
||||||
|
notes: text('notes'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const notifications = sqliteTable('notifications', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
type: text('type', { enum: ['payment_reminder', 'attendance', 'promotion', 'system'] }).notNull(),
|
||||||
|
read: integer('read', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect
|
||||||
|
export type NewUser = typeof users.$inferInsert
|
||||||
|
export type Client = typeof clients.$inferSelect
|
||||||
|
export type NewClient = typeof clients.$inferInsert
|
||||||
|
export type Payment = typeof payments.$inferSelect
|
||||||
|
export type NewPayment = typeof payments.$inferInsert
|
||||||
|
export type Attendance = typeof attendance.$inferSelect
|
||||||
|
export type NewAttendance = typeof attendance.$inferInsert
|
||||||
|
export type Notification = typeof notifications.$inferSelect
|
||||||
|
export type NewNotification = typeof notifications.$inferInsert
|
||||||
19
packages/database/tsconfig.json
Normal file
19
packages/database/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["es2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
17
packages/shared/package.json
Normal file
17
packages/shared/package.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@fitai/shared",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/shared/src/index.ts
Normal file
3
packages/shared/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './types'
|
||||||
|
export * from './schemas'
|
||||||
|
export * from './utils'
|
||||||
46
packages/shared/src/schemas/index.ts
Normal file
46
packages/shared/src/schemas/index.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const UserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
firstName: z.string().min(1),
|
||||||
|
lastName: z.string().min(1),
|
||||||
|
role: z.enum(['admin', 'trainer', 'client']),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ClientSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
membershipType: z.enum(['basic', 'premium', 'vip']),
|
||||||
|
membershipStatus: z.enum(['active', 'inactive', 'suspended']),
|
||||||
|
joinDate: z.date(),
|
||||||
|
lastVisit: z.date().optional(),
|
||||||
|
emergencyContact: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
phone: z.string(),
|
||||||
|
relationship: z.string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PaymentSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
clientId: z.string(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
currency: z.string(),
|
||||||
|
status: z.enum(['pending', 'completed', 'failed', 'refunded']),
|
||||||
|
paymentMethod: z.enum(['cash', 'card', 'bank_transfer']),
|
||||||
|
dueDate: z.date(),
|
||||||
|
paidAt: z.date().optional(),
|
||||||
|
description: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const AttendanceSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
clientId: z.string(),
|
||||||
|
checkInTime: z.date(),
|
||||||
|
checkOutTime: z.date().optional(),
|
||||||
|
type: z.enum(['gym', 'class', 'personal_training']),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
57
packages/shared/src/types/index.ts
Normal file
57
packages/shared/src/types/index.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
role: 'admin' | 'trainer' | 'client'
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
user: User
|
||||||
|
membershipType: 'basic' | 'premium' | 'vip'
|
||||||
|
membershipStatus: 'active' | 'inactive' | 'suspended'
|
||||||
|
joinDate: Date
|
||||||
|
lastVisit?: Date
|
||||||
|
emergencyContact: {
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
relationship: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: string
|
||||||
|
clientId: string
|
||||||
|
client: Client
|
||||||
|
amount: number
|
||||||
|
currency: string
|
||||||
|
status: 'pending' | 'completed' | 'failed' | 'refunded'
|
||||||
|
paymentMethod: 'cash' | 'card' | 'bank_transfer'
|
||||||
|
dueDate: Date
|
||||||
|
paidAt?: Date
|
||||||
|
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 {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
type: 'payment_reminder' | 'attendance' | 'promotion' | 'system'
|
||||||
|
read: boolean
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
26
packages/shared/src/utils/index.ts
Normal file
26
packages/shared/src/utils/index.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export const formatDate = (date: Date): string => {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatCurrency = (
|
||||||
|
amount: number,
|
||||||
|
currency: string = 'USD'
|
||||||
|
): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateDaysBetween = (startDate: Date, endDate: Date): number => {
|
||||||
|
const timeDiff = endDate.getTime() - startDate.getTime()
|
||||||
|
return Math.ceil(timeDiff / (1000 * 3600 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateId = (): string => {
|
||||||
|
return Math.random().toString(36).substr(2, 9)
|
||||||
|
}
|
||||||
19
packages/shared/tsconfig.json
Normal file
19
packages/shared/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["es2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
15
readme.md
Normal file
15
readme.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
## fitai
|
||||||
|
|
||||||
|
# description
|
||||||
|
|
||||||
|
- fitai is integrated ai solution for fitness houses and their clients,
|
||||||
|
its allow to easy menagment of clients, tracking of payments, usage of resourcess,
|
||||||
|
attendance, habits etc.
|
||||||
|
these will be phase one:
|
||||||
|
solution is composed of a admin app, where we are doing managment tasks, we visualize and
|
||||||
|
expose importatnt data to menagment and trainers, and a expo/reactnative mobile app for users.
|
||||||
|
via app we will be tracking attendance and payments, we will be sending notification etc.
|
||||||
|
|
||||||
|
# phase 2
|
||||||
|
we will be tracking user inputs via manual input and devices, backend will analyze data and propose
|
||||||
|
excercises etc.
|
||||||
Loading…
Reference in New Issue
Block a user