Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 368d1645ce |
@@ -1,11 +0,0 @@
|
||||
# Docker ignore file
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env.local
|
||||
.env.development
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
@@ -1,14 +0,0 @@
|
||||
# Database
|
||||
DATABASE_URL="./sqlite.db"
|
||||
|
||||
# NextAuth.js
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-secret-key-here-make-this-very-long-and-random"
|
||||
|
||||
# Email Configuration (Gmail)
|
||||
EMAIL_USER="your-email@gmail.com"
|
||||
EMAIL_PASSWORD="your-app-password-here"
|
||||
|
||||
# Admin Configuration
|
||||
ADMIN_EMAIL="admin@example.com"
|
||||
ADMIN_PASSWORD="admin123"
|
||||
@@ -1,28 +0,0 @@
|
||||
# Production Environment Configuration
|
||||
# Domain: lcc-tt-booking.mikicvi.com
|
||||
|
||||
# Database
|
||||
DATABASE_URL="./data/sqlite.db"
|
||||
|
||||
# NextAuth.js
|
||||
NEXTAUTH_URL="https://lcc-tt-booking.mikicvi.com"
|
||||
NEXTAUTH_SECRET="qHYNaq516ByAY6H4HdxacMICd05I1DqvrTitIuVtT20="
|
||||
|
||||
# Email Configuration (Gmail - Update with your credentials)
|
||||
EMAIL_USER="your-email@gmail.com"
|
||||
EMAIL_PASSWORD="your-app-password-here"
|
||||
|
||||
# Admin Configuration (Change these for production!)
|
||||
ADMIN_EMAIL="admin@lcc-tt-booking.mikicvi.com"
|
||||
ADMIN_PASSWORD="ChangeMeInProduction123!"
|
||||
|
||||
# Application Settings
|
||||
NODE_ENV="production"
|
||||
PORT="3000"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_MAX="100"
|
||||
RATE_LIMIT_WINDOW="900000"
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL="info"
|
||||
+122
-36
@@ -1,21 +1,15 @@
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
@@ -23,30 +17,122 @@ pids
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
# Database Setup Guide
|
||||
|
||||
This guide explains how to set up and manage the database for the Table Tennis Booking System.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Full Setup (Recommended for new installations)
|
||||
|
||||
```bash
|
||||
npm run db:setup
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- ✅ Create all database tables
|
||||
- ✅ Seed essential data (users, courts, settings, time slots)
|
||||
- ✅ Add sample data (bookings, announcements, activity logs)
|
||||
- ✅ Display a comprehensive summary
|
||||
|
||||
### Essential Data Only
|
||||
|
||||
```bash
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
This will set up the database with only essential data, no sample bookings or logs.
|
||||
|
||||
## Database Scripts Overview
|
||||
|
||||
### 🚀 Setup Scripts
|
||||
|
||||
| Script | Command | Description |
|
||||
| ------------------- | ----------------------------------------- | ---------------------------------------- |
|
||||
| **Full Setup** | `npm run db:setup` | Complete database setup with sample data |
|
||||
| **Essential Setup** | `npm run db:seed` | Database setup with essential data only |
|
||||
| **Advanced Setup** | `tsx scripts/setup-database.ts [options]` | Setup with custom options |
|
||||
|
||||
#### Setup Options:
|
||||
|
||||
```bash
|
||||
tsx scripts/setup-database.ts --help # Show all options
|
||||
tsx scripts/setup-database.ts --reset # Reset database first
|
||||
tsx scripts/setup-database.ts --essential-only # No sample data
|
||||
tsx scripts/setup-database.ts --verbose # Show detailed output
|
||||
```
|
||||
|
||||
### 🗑️ Reset Scripts
|
||||
|
||||
| Script | Command | Description |
|
||||
| ------------------- | ----------------------------------- | ------------------------------------ |
|
||||
| **Safe Reset** | `npm run db:reset` | Shows warning, requires confirmation |
|
||||
| **Confirmed Reset** | `npm run db:reset-confirm` | Immediate reset (destructive!) |
|
||||
| **Advanced Reset** | `tsx scripts/reset-db.ts [options]` | Reset with custom options |
|
||||
|
||||
#### Reset Options:
|
||||
|
||||
```bash
|
||||
tsx scripts/reset-db.ts --help # Show all options
|
||||
tsx scripts/reset-db.ts --confirm # Confirm destructive operation
|
||||
tsx scripts/reset-db.ts --verbose # Show detailed output
|
||||
tsx scripts/reset-db.ts --keep-data # Preserve data where possible
|
||||
```
|
||||
|
||||
### 🛠️ Drizzle Scripts
|
||||
|
||||
| Script | Command | Description |
|
||||
| ------------------ | -------------------- | ---------------------------------- |
|
||||
| **Push Schema** | `npm run db:push` | Push schema changes to database |
|
||||
| **Run Migrations** | `npm run db:migrate` | Apply migration files |
|
||||
| **Studio** | `npm run db:studio` | Open Drizzle Studio (database GUI) |
|
||||
|
||||
## Database Structure
|
||||
|
||||
### Tables Created
|
||||
|
||||
1. **users** - User accounts and authentication
|
||||
2. **courts** - Table tennis courts configuration
|
||||
3. **bookings** - Court reservations and scheduling
|
||||
4. **announcements** - System announcements and notifications
|
||||
5. **time_slots** - Available booking time slots per day
|
||||
6. **settings** - System configuration and preferences
|
||||
7. **activity_logs** - User action tracking and audit trail
|
||||
8. **metrics** - System performance and usage metrics
|
||||
|
||||
### Default Data
|
||||
|
||||
#### Users
|
||||
|
||||
- **Admin User**: `admin@tabletennis.com` / `admin123`
|
||||
- **Test User**: `user@tabletennis.com` / `user123`
|
||||
|
||||
#### Courts
|
||||
|
||||
- Court 1 (Active)
|
||||
- Court 2 (Active)
|
||||
- Court 3 (Active)
|
||||
|
||||
#### Time Slots
|
||||
|
||||
- **Sunday**: 12:00 - 17:00
|
||||
- **Monday-Thursday**: 18:00 - 23:00
|
||||
- **Friday**: 17:00 - 22:00
|
||||
- **Saturday**: 10:00 - 18:00
|
||||
|
||||
#### Settings
|
||||
|
||||
- Booking window: 14 days
|
||||
- Max booking duration: 2 hours
|
||||
- Min booking duration: 60 minutes
|
||||
- Booking modifications allowed: Yes
|
||||
- Modification cutoff: 2 hours before booking
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 🆕 New Project Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd tt-booking
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Set up database with full sample data
|
||||
npm run db:setup
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 🔄 Development Reset
|
||||
|
||||
```bash
|
||||
# Reset and rebuild database
|
||||
npm run db:reset-confirm
|
||||
npm run db:setup
|
||||
|
||||
# Or combine in one command
|
||||
tsx scripts/setup-database.ts --reset --verbose
|
||||
```
|
||||
|
||||
### 🚀 Production Setup
|
||||
|
||||
```bash
|
||||
# Essential data only (no sample bookings)
|
||||
npm run db:seed
|
||||
|
||||
# Or with custom options
|
||||
tsx scripts/setup-database.ts --essential-only --verbose
|
||||
```
|
||||
|
||||
### 🧪 Testing Environment
|
||||
|
||||
```bash
|
||||
# Reset database and add fresh test data
|
||||
tsx scripts/reset-db.ts --confirm --verbose
|
||||
tsx scripts/setup-database.ts --verbose
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Time Slots
|
||||
|
||||
Edit the time slots in `scripts/setup-database.ts`:
|
||||
|
||||
```typescript
|
||||
const timeSlotData = [
|
||||
{ dayOfWeek: 0, startTime: '10:00', endTime: '16:00' }, // Sunday
|
||||
{ dayOfWeek: 1, startTime: '17:00', endTime: '22:00' }, // Monday
|
||||
// ... customize as needed
|
||||
];
|
||||
```
|
||||
|
||||
### Custom Settings
|
||||
|
||||
Modify default settings in the `seedBasicData` function:
|
||||
|
||||
```typescript
|
||||
const defaultSettings = [
|
||||
{ key: 'booking_window_days', value: '7' }, // 7 days ahead
|
||||
{ key: 'max_booking_duration_hours', value: '3' }, // 3 hour max
|
||||
// ... add more settings
|
||||
];
|
||||
```
|
||||
|
||||
### Additional Users
|
||||
|
||||
Add more users in the `seedBasicData` or `seedSampleData` functions:
|
||||
|
||||
```typescript
|
||||
await db.insert(schema.users).values({
|
||||
id: randomUUID(),
|
||||
email: 'coach@tabletennis.com',
|
||||
name: 'Head',
|
||||
surname: 'Coach',
|
||||
password: await bcrypt.hash('coach123', 12),
|
||||
role: 'user',
|
||||
themePreference: 'system',
|
||||
createdAt: new Date(now),
|
||||
updatedAt: new Date(now),
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Database locked error**
|
||||
|
||||
```bash
|
||||
# Close any running applications using the database
|
||||
# Then reset and recreate
|
||||
npm run db:reset-confirm
|
||||
npm run db:setup
|
||||
```
|
||||
|
||||
**Schema mismatch**
|
||||
|
||||
```bash
|
||||
# Push latest schema changes
|
||||
npm run db:push
|
||||
|
||||
# Or reset and rebuild
|
||||
npm run db:reset-confirm
|
||||
npm run db:setup
|
||||
```
|
||||
|
||||
**Permission errors**
|
||||
|
||||
```bash
|
||||
# Check file permissions
|
||||
ls -la sqlite.db
|
||||
|
||||
# If needed, fix permissions
|
||||
chmod 666 sqlite.db
|
||||
```
|
||||
|
||||
### Database Location
|
||||
|
||||
The SQLite database file is located at: `./sqlite.db`
|
||||
|
||||
You can:
|
||||
|
||||
- View it with any SQLite browser
|
||||
- Open it in Drizzle Studio: `npm run db:studio`
|
||||
- Back it up by copying the file
|
||||
- Remove it completely and recreate with setup scripts
|
||||
|
||||
### Getting Help
|
||||
|
||||
```bash
|
||||
# Show help for setup script
|
||||
tsx scripts/setup-database.ts --help
|
||||
|
||||
# Show help for reset script
|
||||
tsx scripts/reset-db.ts --help
|
||||
|
||||
# Show all available npm scripts
|
||||
npm run
|
||||
```
|
||||
|
||||
## Migration from Old Scripts
|
||||
|
||||
The new unified scripts replace the old individual seed scripts:
|
||||
|
||||
| Old Script | New Equivalent |
|
||||
| ------------------------------- | ----------------------------------- |
|
||||
| `scripts/seed-data.ts` | Integrated into `setup-database.ts` |
|
||||
| `scripts/seed-announcements.ts` | Integrated into `setup-database.ts` |
|
||||
| `scripts/seed-time-slots.ts` | Integrated into `setup-database.ts` |
|
||||
| `scripts/init-admin-data.ts` | Integrated into `setup-database.ts` |
|
||||
| `scripts/reset-database.ts` | Replaced by `reset-db.ts` |
|
||||
|
||||
The old scripts can be safely removed as all functionality is now consolidated in the new, more intelligent setup system.
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Check the terminal output for detailed information and suggestions, or refer to the help commands above.
|
||||
@@ -1,506 +0,0 @@
|
||||
# Deployment Strategy for Table Tennis Booking System
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines comprehensive deployment strategies for the Table Tennis Booking System, considering both self-hosting and cloud deployment options. The application is a Next.js-based system with SQLite database, designed for production use.
|
||||
|
||||
## 1. Self-Hosting Strategy
|
||||
|
||||
### Option A: Raspberry Pi + Cloudflare Tunnel (Recommended)
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
Internet → Cloudflare → Cloudflare Tunnel → Raspberry Pi → Docker Container
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Raspberry Pi 4 (4GB+ RAM recommended)
|
||||
- Stable internet connection
|
||||
- Cloudflare account (free tier sufficient)
|
||||
- Domain name (can be managed through Cloudflare)
|
||||
|
||||
**Setup Steps:**
|
||||
|
||||
1. **Raspberry Pi Preparation**
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Install Docker Compose
|
||||
sudo apt install docker-compose -y
|
||||
```
|
||||
|
||||
2. **Application Deployment**
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <your-repo-url>
|
||||
cd tt-booking
|
||||
|
||||
# Create production environment file
|
||||
cp .env.example .env.production
|
||||
# Edit .env.production with your values
|
||||
|
||||
# Deploy with Docker
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
3. **Cloudflare Tunnel Setup**
|
||||
|
||||
```bash
|
||||
# Install cloudflared
|
||||
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb
|
||||
sudo dpkg -i cloudflared-linux-arm64.deb
|
||||
|
||||
# Authenticate
|
||||
cloudflared tunnel login
|
||||
|
||||
# Create tunnel
|
||||
cloudflared tunnel create tt-booking
|
||||
|
||||
# Configure tunnel (create config.yml)
|
||||
cloudflared tunnel route dns tt-booking yourdomain.com
|
||||
|
||||
# Run tunnel
|
||||
cloudflared tunnel run tt-booking
|
||||
```
|
||||
|
||||
**Cloudflare Tunnel Config (`~/.cloudflared/config.yml`):**
|
||||
|
||||
```yaml
|
||||
tunnel: <tunnel-id>
|
||||
credentials-file: /home/pi/.cloudflared/<tunnel-id>.json
|
||||
|
||||
ingress:
|
||||
- hostname: yourdomain.com
|
||||
service: http://localhost:3000
|
||||
- service: http_status:404
|
||||
```
|
||||
|
||||
**Production Docker Compose (`docker-compose.production.yml`):**
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
tt-booking:
|
||||
build: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=/app/data/sqlite.db
|
||||
- NEXTAUTH_URL=https://yourdomain.com
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- EMAIL_USER=${EMAIL_USER}
|
||||
- EMAIL_PASSWORD=${EMAIL_PASSWORD}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Backup service
|
||||
backup:
|
||||
image: alpine:latest
|
||||
volumes:
|
||||
- ./data:/data:ro
|
||||
- ./backups:/backups
|
||||
command: >
|
||||
sh -c "
|
||||
while true; do
|
||||
cp /data/sqlite.db /backups/sqlite-$(date +%Y%m%d-%H%M%S).db
|
||||
find /backups -name 'sqlite-*.db' -mtime +7 -delete
|
||||
sleep 86400
|
||||
done"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
|
||||
- No need for port forwarding or exposing home IP
|
||||
- Free SSL certificates through Cloudflare
|
||||
- DDoS protection and CDN benefits
|
||||
- Easy domain management
|
||||
- Cost-effective (only domain cost ~$10-15/year)
|
||||
|
||||
**Disadvantages:**
|
||||
|
||||
- Dependent on home internet stability
|
||||
- Limited by residential bandwidth
|
||||
- Requires basic Linux administration skills
|
||||
|
||||
### Option B: Traditional Self-Hosting with Reverse Proxy
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
Internet → Router/Firewall → Nginx → Docker Container
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Dedicated server or powerful Raspberry Pi
|
||||
- Static IP address or Dynamic DNS service
|
||||
- SSL certificate (Let's Encrypt)
|
||||
- Port forwarding configuration
|
||||
|
||||
**Setup includes all the Docker setup above, plus:**
|
||||
|
||||
1. **Nginx Configuration**
|
||||
|
||||
```bash
|
||||
# Install Nginx
|
||||
sudo apt install nginx certbot python3-certbot-nginx
|
||||
|
||||
# Configure SSL
|
||||
sudo certbot --nginx -d yourdomain.com
|
||||
```
|
||||
|
||||
2. **Updated Docker Compose with Nginx**
|
||||
Use the existing [docker-compose.yml](docker-compose.yml) with Nginx service.
|
||||
|
||||
**Advantages:**
|
||||
|
||||
- Full control over infrastructure
|
||||
- No dependency on third-party tunneling services
|
||||
- Better performance for local network access
|
||||
|
||||
**Disadvantages:**
|
||||
|
||||
- Requires static IP or Dynamic DNS
|
||||
- More complex firewall/security configuration
|
||||
- SSL certificate management overhead
|
||||
|
||||
## 2. Cloud Deployment Strategies
|
||||
|
||||
### Option A: DigitalOcean App Platform (Recommended for Small Scale)
|
||||
|
||||
**Cost Estimate:** $12-25/month
|
||||
|
||||
**Features:**
|
||||
|
||||
- Automatic deployments from Git
|
||||
- Built-in SSL certificates
|
||||
- Automatic scaling
|
||||
- Integrated monitoring
|
||||
|
||||
**Deployment:**
|
||||
|
||||
1. Connect GitHub repository
|
||||
2. Configure environment variables
|
||||
3. Add persistent volume for SQLite database
|
||||
4. Deploy with zero-config
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```yaml
|
||||
# .do/app.yaml
|
||||
name: tt-booking
|
||||
services:
|
||||
- name: web
|
||||
source_dir: /
|
||||
github:
|
||||
repo: your-username/tt-booking
|
||||
branch: main
|
||||
run_command: npm start
|
||||
environment_slug: node-js
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
envs:
|
||||
- key: NODE_ENV
|
||||
value: production
|
||||
- key: DATABASE_URL
|
||||
value: /app/data/sqlite.db
|
||||
```
|
||||
|
||||
### Option B: Railway (Developer-Friendly)
|
||||
|
||||
**Cost Estimate:** $5-20/month
|
||||
|
||||
**Features:**
|
||||
|
||||
- Git-based deployments
|
||||
- Built-in databases available
|
||||
- Simple pricing model
|
||||
- Excellent developer experience
|
||||
|
||||
**Deployment:**
|
||||
|
||||
```bash
|
||||
# Install Railway CLI
|
||||
npm install -g @railway/cli
|
||||
|
||||
# Login and deploy
|
||||
railway login
|
||||
railway init
|
||||
railway up
|
||||
```
|
||||
|
||||
### Option C: Vercel + PlanetScale (Serverless)
|
||||
|
||||
**Cost Estimate:** $0-20/month (depending on usage)
|
||||
|
||||
**Modifications needed:**
|
||||
|
||||
- Replace SQLite with PlanetScale MySQL
|
||||
- Update database schema for MySQL compatibility
|
||||
- Modify connection configuration
|
||||
|
||||
**Deployment:**
|
||||
|
||||
```bash
|
||||
# Install Vercel CLI
|
||||
npm install -g vercel
|
||||
|
||||
# Deploy
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
### Option D: AWS/GCP/Azure (Enterprise Scale)
|
||||
|
||||
**Cost Estimate:** $30-100+/month
|
||||
|
||||
**AWS Deployment Options:**
|
||||
|
||||
1. **ECS Fargate + RDS**
|
||||
|
||||
- Container-based deployment
|
||||
- Managed database
|
||||
- Auto-scaling capabilities
|
||||
|
||||
2. **Elastic Beanstalk**
|
||||
|
||||
- Simple deployment model
|
||||
- Built-in monitoring
|
||||
- Easy environment management
|
||||
|
||||
3. **App Runner**
|
||||
- Serverless container platform
|
||||
- Automatic scaling
|
||||
- Pay-per-use pricing
|
||||
|
||||
## 3. Database Considerations
|
||||
|
||||
### For Self-Hosting
|
||||
|
||||
- **SQLite**: Perfect for small to medium loads
|
||||
- **Backup Strategy**: Automated daily backups to external storage
|
||||
- **Monitoring**: Simple file-based health checks
|
||||
|
||||
### For Cloud Deployment
|
||||
|
||||
- **Small Scale**: Keep SQLite with persistent volumes
|
||||
- **Medium Scale**: PostgreSQL (Railway, DigitalOcean Managed DB)
|
||||
- **Large Scale**: Multi-region database (AWS RDS, Google Cloud SQL)
|
||||
|
||||
## 4. Monitoring and Maintenance
|
||||
|
||||
### Essential Monitoring
|
||||
|
||||
```bash
|
||||
# Add to crontab for health checks
|
||||
*/5 * * * * curl -f https://yourdomain.com/api/health || echo "App down" | mail -s "Alert" admin@example.com
|
||||
```
|
||||
|
||||
### Backup Strategy
|
||||
|
||||
1. **Database Backups**: Daily automated SQLite file copies
|
||||
2. **Environment Config**: Version controlled `.env` files
|
||||
3. **Application Code**: Git-based source control
|
||||
|
||||
### Update Strategy
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# update.sh
|
||||
cd /path/to/tt-booking
|
||||
git pull origin main
|
||||
docker-compose -f docker-compose.production.yml down
|
||||
docker-compose -f docker-compose.production.yml up -d --build
|
||||
```
|
||||
|
||||
## 5. Security Considerations
|
||||
|
||||
### Self-Hosting Security Checklist
|
||||
|
||||
- [ ] Firewall configured (only necessary ports open)
|
||||
- [ ] Regular OS updates automated
|
||||
- [ ] Non-root user for application
|
||||
- [ ] SSL certificates properly configured
|
||||
- [ ] Database backups encrypted
|
||||
- [ ] Rate limiting configured (already in nginx.conf)
|
||||
- [ ] Log monitoring for suspicious activity
|
||||
|
||||
### Cloud Security
|
||||
|
||||
- [ ] Environment variables properly secured
|
||||
- [ ] Database access restricted
|
||||
- [ ] API rate limiting enabled
|
||||
- [ ] Regular dependency updates
|
||||
- [ ] Security headers configured (already in app)
|
||||
|
||||
## 6. Cost Comparison
|
||||
|
||||
| Deployment Method | Monthly Cost | Effort | Scalability | Reliability |
|
||||
| ------------------------- | ------------ | -------- | ----------- | ----------- |
|
||||
| Raspberry Pi + CF Tunnel | $1-2 | Medium | Low | Medium |
|
||||
| Traditional Self-Host | $5-10 | High | Low | Medium |
|
||||
| DigitalOcean App Platform | $12-25 | Low | Medium | High |
|
||||
| Railway | $5-20 | Very Low | Medium | High |
|
||||
| Vercel + PlanetScale | $0-20 | Low | High | High |
|
||||
| AWS/GCP/Azure | $30-100+ | High | Very High | Very High |
|
||||
|
||||
## 7. Recommended Approach
|
||||
|
||||
### For Personal/Small Group Use:
|
||||
|
||||
**Raspberry Pi + Cloudflare Tunnel** - Most cost-effective with good reliability
|
||||
|
||||
### For Small Business:
|
||||
|
||||
**Railway or DigitalOcean App Platform** - Balance of simplicity and reliability
|
||||
|
||||
### For Enterprise:
|
||||
|
||||
**AWS/GCP with proper CI/CD pipeline** - Maximum scalability and reliability
|
||||
|
||||
## 8. Local Development Best Practices
|
||||
|
||||
### Standalone Development
|
||||
|
||||
```bash
|
||||
# Quick development setup
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Docker Development
|
||||
|
||||
```bash
|
||||
# Development with Docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Production-like Local Testing
|
||||
|
||||
```bash
|
||||
# Test production build locally
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## 9. Health Check Endpoint
|
||||
|
||||
The application includes a health check endpoint at `/api/health` for monitoring purposes. You should create this endpoint:
|
||||
|
||||
```typescript
|
||||
// app/api/health/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Basic database connectivity check
|
||||
await db.select().from(settings).limit(1);
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ status: 'unhealthy', error: 'Database connection failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Environment Variables for Production
|
||||
|
||||
Create a `.env.production` file with the following required variables:
|
||||
|
||||
```bash
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXTAUTH_URL=https://yourdomain.com
|
||||
NEXTAUTH_SECRET=your-very-long-random-secret-here
|
||||
|
||||
# Database
|
||||
DATABASE_URL=/app/data/sqlite.db
|
||||
|
||||
# Email Configuration (optional)
|
||||
EMAIL_USER=your-email@gmail.com
|
||||
EMAIL_PASSWORD=your-app-specific-password
|
||||
|
||||
# Admin Account
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
ADMIN_PASSWORD=secure-admin-password
|
||||
|
||||
# Optional: Rate limiting
|
||||
RATE_LIMIT_MAX=100
|
||||
RATE_LIMIT_WINDOW=900000
|
||||
```
|
||||
|
||||
## 11. Docker Production Optimization
|
||||
|
||||
Create a production-optimized `Dockerfile.production`:
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
This deployment strategy provides multiple pathways depending on your technical expertise, budget, and scaling requirements. The Cloudflare Tunnel approach is particularly attractive for self-hosting as it eliminates many traditional networking complexities while maintaining security and reliability.
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
# Use the official Node.js runtime as the base image
|
||||
FROM node:18-alpine
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (if available)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Create the SQLite database directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Build the Next.js application
|
||||
RUN npm run build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV DATABASE_URL=/app/data/sqlite.db
|
||||
|
||||
# Create a non-root user to run the application
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nextjs -u 1001
|
||||
|
||||
# Change ownership of the app directory to the nextjs user
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
USER nextjs
|
||||
|
||||
# Command to run the application
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,74 +0,0 @@
|
||||
# Multi-stage production Dockerfile for LCC Table Tennis Booking
|
||||
FROM node:22-slim AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN \
|
||||
if [ -f package-lock.json ]; then npm ci --ignore-scripts; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Rebuild better-sqlite3 for Alpine Linux
|
||||
RUN npm rebuild better-sqlite3
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create system user and group
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy necessary files from builder stage
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Create public directory if it doesn't exist
|
||||
RUN mkdir -p public
|
||||
|
||||
# Create directories for data and backups
|
||||
RUN mkdir -p /app/data /app/backups /app/logs && \
|
||||
chown -R nextjs:nodejs /app/data /app/backups /app/logs
|
||||
|
||||
# Create startup script
|
||||
COPY --chown=nextjs:nodejs <<EOF /app/start.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "🚀 Starting TT Booking Production..."
|
||||
echo "🌟 Starting server..."
|
||||
exec node server.js
|
||||
EOF
|
||||
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["/app/start.sh"]
|
||||
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 mikicv
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,149 +0,0 @@
|
||||
# LCC Table Tennis Booking - Production Setup
|
||||
|
||||
## Quick Start
|
||||
|
||||
Your production environment is configured for domain: **lcc-tt-booking.mikicvi.com**
|
||||
|
||||
### 1. Deploy the Application
|
||||
|
||||
```bash
|
||||
# Make deployment script executable (if not already done)
|
||||
chmod +x deploy.sh
|
||||
|
||||
# Deploy to production
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 2. Set Up Cloudflare Tunnel
|
||||
|
||||
```bash
|
||||
# Run the tunnel setup script
|
||||
./setup-tunnel.sh
|
||||
|
||||
# Follow the instructions provided by the script
|
||||
```
|
||||
|
||||
## Configuration Files Created
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
- **`.env.production`** - Production environment variables
|
||||
- **`docker-compose.production.yml`** - Production Docker Compose configuration
|
||||
- **`Dockerfile.production`** - Optimized production Docker build
|
||||
|
||||
### Security & Secrets
|
||||
|
||||
- **NEXTAUTH_SECRET**: `qHYNaq516ByAY6H4HdxacMICd05I1DqvrTitIuVtT20=` (pre-generated)
|
||||
- **Domain**: `lcc-tt-booking.mikicvi.com`
|
||||
- **Admin Email**: `admin@lcc-tt-booking.mikicvi.com`
|
||||
|
||||
### Scripts & Automation
|
||||
|
||||
- **`deploy.sh`** - One-command production deployment
|
||||
- **`setup-tunnel.sh`** - Cloudflare Tunnel setup assistant
|
||||
- **`cloudflare-tunnel-config.yml`** - Tunnel configuration template
|
||||
|
||||
### Health & Monitoring
|
||||
|
||||
- **`/api/health`** - Health check endpoint with database connectivity and memory usage
|
||||
- **Automated backups** - Daily SQLite backups (30-day retention)
|
||||
- **Log rotation** - Automatic log management
|
||||
|
||||
## Production Checklist
|
||||
|
||||
### Before Going Live:
|
||||
|
||||
- [ ] Update email credentials in `.env.production`
|
||||
- [ ] Change admin password from default
|
||||
- [ ] Set up Cloudflare Tunnel
|
||||
- [ ] Test health check endpoint
|
||||
- [ ] Verify SSL certificate is working
|
||||
|
||||
### After Deployment:
|
||||
|
||||
- [ ] Test all booking functionality
|
||||
- [ ] Verify admin panel access
|
||||
- [ ] Check automated backups are working
|
||||
- [ ] Set up monitoring alerts (optional)
|
||||
|
||||
## Management Commands
|
||||
|
||||
```bash
|
||||
# View application logs
|
||||
docker-compose -f docker-compose.production.yml logs -f tt-booking
|
||||
|
||||
# Restart application
|
||||
docker-compose -f docker-compose.production.yml restart tt-booking
|
||||
|
||||
# Stop all services
|
||||
docker-compose -f docker-compose.production.yml down
|
||||
|
||||
# Update and redeploy
|
||||
./deploy.sh
|
||||
|
||||
# Access database backup
|
||||
ls -la backups/
|
||||
|
||||
# Check application health
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
/Users/mikicv/Documents/tt-booking/
|
||||
├── .env.production # Production environment variables
|
||||
├── docker-compose.production.yml # Production Docker Compose
|
||||
├── Dockerfile.production # Production Docker build
|
||||
├── deploy.sh # Deployment script
|
||||
├── setup-tunnel.sh # Cloudflare Tunnel setup
|
||||
├── cloudflare-tunnel-config.yml # Tunnel configuration
|
||||
├── data/ # SQLite database storage
|
||||
├── backups/ # Automated backups
|
||||
└── logs/ # Application logs
|
||||
```
|
||||
|
||||
## Default Admin Access
|
||||
|
||||
- **URL**: https://lcc-tt-booking.mikicvi.com/admin
|
||||
- **Email**: admin@lcc-tt-booking.mikicvi.com
|
||||
- **Password**: ChangeMeInProduction123! (⚠️ CHANGE THIS!)
|
||||
|
||||
## Support & Troubleshooting
|
||||
|
||||
### Common Issues:
|
||||
|
||||
1. **Container won't start**: Check `docker-compose -f docker-compose.production.yml logs`
|
||||
2. **Database issues**: Ensure `data/` directory permissions are correct
|
||||
3. **Tunnel not working**: Verify Cloudflare DNS settings and tunnel configuration
|
||||
|
||||
### Health Check:
|
||||
|
||||
The health endpoint (`/api/health`) provides:
|
||||
|
||||
- Application status
|
||||
- Database connectivity
|
||||
- Memory usage
|
||||
- Uptime information
|
||||
|
||||
### Backup Verification:
|
||||
|
||||
```bash
|
||||
# List all backups
|
||||
ls -la backups/
|
||||
|
||||
# Check latest backup size
|
||||
du -h backups/sqlite-$(date +%Y%m%d)*.db | tail -1
|
||||
```
|
||||
|
||||
## Production Features Included:
|
||||
|
||||
- ✅ Automated daily backups (30-day retention)
|
||||
- ✅ Health monitoring endpoint
|
||||
- ✅ Log rotation and management
|
||||
- ✅ Multi-stage Docker optimization
|
||||
- ✅ Security hardening
|
||||
- ✅ Rate limiting configured
|
||||
- ✅ SSL-ready with Cloudflare integration
|
||||
|
||||
Your LCC Table Tennis Booking System is ready for production! 🏓
|
||||
@@ -1,201 +1,3 @@
|
||||
# Table Tennis Booking System
|
||||
# tt-booking
|
||||
|
||||
A modern, full-stack table tennis court booking system built with Next.js, shadcn/ui, and SQLite.
|
||||
|
||||
## Features
|
||||
|
||||
### User Features
|
||||
|
||||
- **Secure Authentication**: User registration and login with JWT tokens
|
||||
- **Court Booking**: Interactive booking calendar with real-time availability
|
||||
- **Email Notifications**: Automatic confirmation and cancellation emails
|
||||
- **Mobile-First Design**: Responsive UI that works on all devices
|
||||
- **Booking Management**: View and manage your bookings
|
||||
|
||||
### Admin Features
|
||||
|
||||
- **Court Management**: Add/remove courts and configure availability
|
||||
- **Time Slot Configuration**: Set operating hours for different days
|
||||
- **User Management**: View and manage user accounts
|
||||
- **Booking Override**: Admin can edit or cancel any booking
|
||||
- **Announcements**: Create and manage system announcements
|
||||
- **Activity Logs**: Comprehensive logging of all system activities
|
||||
- **Analytics Dashboard**: Booking statistics and usage metrics
|
||||
|
||||
### System Features
|
||||
|
||||
- **7-Day Booking Window**: Users can only book up to 1 week in advance
|
||||
- **Real-time Validation**: Both client and server-side booking validation
|
||||
- **Secure Backend**: SQLite database with Drizzle ORM
|
||||
- **Docker Support**: Easy deployment with Docker and reverse proxy
|
||||
- **Email Integration**: Gmail SMTP integration for notifications
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: Next.js 14, React, TypeScript
|
||||
- **UI Components**: shadcn/ui, Tailwind CSS, Radix UI
|
||||
- **Backend**: Next.js API routes, Drizzle ORM
|
||||
- **Database**: SQLite
|
||||
- **Authentication**: JWT tokens with httpOnly cookies
|
||||
- **Email**: Nodemailer with Gmail
|
||||
- **Deployment**: Docker, Nginx reverse proxy
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- Gmail account for email notifications
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd tt-booking
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up environment variables**
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` with your configuration:
|
||||
|
||||
```env
|
||||
NEXTAUTH_SECRET="your-secret-key-here-make-this-very-long-and-random"
|
||||
EMAIL_USER="your-email@gmail.com"
|
||||
EMAIL_PASSWORD="your-gmail-app-password"
|
||||
ADMIN_EMAIL="admin@example.com"
|
||||
ADMIN_PASSWORD="admin123"
|
||||
```
|
||||
|
||||
4. **Set up the database**
|
||||
|
||||
```bash
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
5. **Run the development server**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. **Access the application**
|
||||
- User interface: http://localhost:3000
|
||||
- Admin panel: http://localhost:3000/admin
|
||||
|
||||
## Configuration
|
||||
|
||||
### Gmail Setup
|
||||
|
||||
1. Enable 2-factor authentication on your Gmail account
|
||||
2. Generate an App Password: Google Account > Security > App passwords
|
||||
3. Use the App Password as `EMAIL_PASSWORD` in your environment variables
|
||||
|
||||
### Default Settings
|
||||
|
||||
- **Courts**: 2 courts (configurable via admin panel)
|
||||
- **Monday/Tuesday**: 19:00-23:00 (configurable)
|
||||
- **Sunday**: 12:00-17:00 (configurable)
|
||||
- **Booking window**: 7 days from current date
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
1. **Update environment variables** in `docker-compose.yml`
|
||||
2. **Configure SSL certificates** in the `ssl` directory
|
||||
3. **Update domain** in `nginx.conf`
|
||||
4. **Deploy**:
|
||||
```bash
|
||||
docker-compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
tt-booking/
|
||||
├── app/ # Next.js app directory
|
||||
│ ├── api/ # API routes
|
||||
│ ├── dashboard/ # User dashboard
|
||||
│ ├── admin/ # Admin panel
|
||||
│ └── layout.tsx # Root layout
|
||||
├── components/ # React components
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ ├── auth/ # Authentication forms
|
||||
│ ├── booking/ # Booking components
|
||||
│ └── admin/ # Admin components
|
||||
├── lib/ # Utility libraries
|
||||
│ ├── db/ # Database schema and connection
|
||||
│ ├── auth.ts # Authentication utilities
|
||||
│ ├── email.ts # Email functionality
|
||||
│ └── utils.ts # General utilities
|
||||
├── docker-compose.yml # Docker configuration
|
||||
├── Dockerfile # Container definition
|
||||
└── nginx.conf # Reverse proxy configuration
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
- `POST /api/auth/login` - User login
|
||||
- `POST /api/auth/register` - User registration
|
||||
- `POST /api/auth/logout` - User logout
|
||||
|
||||
### Bookings
|
||||
|
||||
- `GET /api/bookings` - Get user bookings
|
||||
- `POST /api/bookings` - Create booking
|
||||
- `PUT /api/bookings/[id]` - Update booking
|
||||
- `DELETE /api/bookings/[id]` - Cancel booking
|
||||
|
||||
### Admin
|
||||
|
||||
- `GET /api/admin/stats` - Dashboard statistics
|
||||
- `GET /api/admin/courts` - Manage courts
|
||||
- `GET /api/admin/settings` - System settings
|
||||
- `GET /api/admin/logs` - Activity logs
|
||||
|
||||
## Security Features
|
||||
|
||||
- **Rate Limiting**: API endpoints are rate-limited via Nginx
|
||||
- **CSRF Protection**: Built-in Next.js CSRF protection
|
||||
- **SQL Injection Prevention**: Drizzle ORM parameterized queries
|
||||
- **XSS Protection**: Content Security Policy headers
|
||||
- **Secure Cookies**: httpOnly, secure, sameSite cookies
|
||||
- **Input Validation**: Zod schema validation
|
||||
- **Password Hashing**: bcrypt with salt rounds
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions, please create an issue in the repository.
|
||||
Fully customisable court booking application
|
||||
@@ -1,5 +0,0 @@
|
||||
import { AdminDashboard } from '@/components/admin/admin-dashboard';
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { announcements } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { title, content, priority, expiresAt, isActive } = await request.json();
|
||||
const { id } = await context.params;
|
||||
const announcementId = id;
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json({ error: 'Title and content are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if announcement exists
|
||||
const existing = await db.select().from(announcements).where(eq(announcements.id, announcementId)).limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Update announcement
|
||||
const [updatedAnnouncement] = await db
|
||||
.update(announcements)
|
||||
.set({
|
||||
title,
|
||||
content,
|
||||
priority: priority || 'medium',
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(announcements.id, announcementId))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
announcement: updatedAnnouncement,
|
||||
message: 'Announcement updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating announcement:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const announcementId = id;
|
||||
|
||||
// Check if announcement exists
|
||||
const existing = await db.select().from(announcements).where(eq(announcements.id, announcementId)).limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete announcement
|
||||
await db.delete(announcements).where(eq(announcements.id, announcementId));
|
||||
|
||||
return NextResponse.json({ message: 'Announcement deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting announcement:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { announcements } from '@/lib/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Regular users see only active announcements, admins see all
|
||||
const allAnnouncements = await db
|
||||
.select()
|
||||
.from(announcements)
|
||||
.where(session.role === 'admin' ? undefined : eq(announcements.isActive, true))
|
||||
.orderBy(desc(announcements.createdAt));
|
||||
|
||||
return NextResponse.json({ announcements: allAnnouncements });
|
||||
} catch (error) {
|
||||
console.error('Error fetching announcements:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { title, content, priority, expiresAt } = await request.json();
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json({ error: 'Title and content are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [newAnnouncement] = await db
|
||||
.insert(announcements)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
content,
|
||||
priority: priority || 'medium',
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
announcement: newAnnouncement,
|
||||
message: 'Announcement created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating announcement:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { courts } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name, isActive } = await request.json();
|
||||
const { id } = await context.params;
|
||||
const courtId = id;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Court name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if court exists
|
||||
const existing = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json({ error: 'Court not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Update court
|
||||
const [updatedCourt] = await db
|
||||
.update(courts)
|
||||
.set({
|
||||
name,
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(courts.id, courtId))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
court: updatedCourt,
|
||||
message: 'Court updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating court:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const courtId = id;
|
||||
|
||||
// Check if court exists
|
||||
const existing = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json({ error: 'Court not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete court (this will cascade to bookings due to foreign key)
|
||||
await db.delete(courts).where(eq(courts.id, courtId));
|
||||
|
||||
return NextResponse.json({ message: 'Court deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting court:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { courts } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Regular users see only active courts, admins see all
|
||||
const allCourts = await db
|
||||
.select()
|
||||
.from(courts)
|
||||
.where(session.role === 'admin' ? undefined : eq(courts.isActive, true));
|
||||
|
||||
return NextResponse.json({ courts: allCourts });
|
||||
} catch (error) {
|
||||
console.error('Error fetching courts:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name, isActive } = await request.json();
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Court name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [newCourt] = await db
|
||||
.insert(courts)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
court: newCourt,
|
||||
message: 'Court created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating court:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifySession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { activityLogs, users } from '@/lib/db/schema';
|
||||
import { eq, desc, isNull, or } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await verifySession();
|
||||
if (!session.isAuth || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
|
||||
// Get activity logs with user details
|
||||
const logs = await db
|
||||
.select({
|
||||
id: activityLogs.id,
|
||||
action: activityLogs.action,
|
||||
entityType: activityLogs.entityType,
|
||||
entityId: activityLogs.entityId,
|
||||
details: activityLogs.details,
|
||||
ipAddress: activityLogs.ipAddress,
|
||||
userAgent: activityLogs.userAgent,
|
||||
createdAt: activityLogs.createdAt,
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
email: users.email,
|
||||
},
|
||||
})
|
||||
.from(activityLogs)
|
||||
.leftJoin(users, eq(activityLogs.userId, users.id))
|
||||
.orderBy(desc(activityLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
logs,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore: logs.length === limit,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity logs:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch activity logs' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, entityType, entityId, details, ipAddress, userAgent } = body;
|
||||
|
||||
if (!action || !entityType) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await verifySession();
|
||||
const userId = session.isAuth ? session.userId : null;
|
||||
|
||||
// Create activity log
|
||||
const [log] = await db
|
||||
.insert(activityLogs)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
details: details ? JSON.stringify(details) : null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
log,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating activity log:', error);
|
||||
return NextResponse.json({ error: 'Failed to create activity log' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifySession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { bookings, users, courts } from '@/lib/db/schema';
|
||||
import { eq, desc, gte } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await verifySession();
|
||||
if (!session.isAuth || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
|
||||
// Get recent bookings with user and court details
|
||||
const recentBookings = await db
|
||||
.select({
|
||||
id: bookings.id,
|
||||
date: bookings.date,
|
||||
startTime: bookings.startTime,
|
||||
endTime: bookings.endTime,
|
||||
status: bookings.status,
|
||||
notes: bookings.notes,
|
||||
createdAt: bookings.createdAt,
|
||||
court: {
|
||||
id: courts.id,
|
||||
name: courts.name,
|
||||
},
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
email: users.email,
|
||||
},
|
||||
})
|
||||
.from(bookings)
|
||||
.innerJoin(courts, eq(bookings.courtId, courts.id))
|
||||
.innerJoin(users, eq(bookings.userId, users.id))
|
||||
.orderBy(desc(bookings.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bookings: recentBookings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin recent bookings:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch recent bookings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { settings } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { invalidateAppConfigCache } from '@/lib/app-config';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const allSettings = await db.select().from(settings);
|
||||
|
||||
// Convert to key-value object for easier use
|
||||
const settingsObj = allSettings.reduce((acc: any, setting) => {
|
||||
acc[setting.key] = setting.value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return NextResponse.json({ settings: allSettings });
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { settings: newSettings } = await request.json();
|
||||
|
||||
if (!newSettings || !Array.isArray(newSettings)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid settings format. Expected array of {key, value} objects' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update each setting
|
||||
const updatePromises = newSettings.map(async ({ key, value }) => {
|
||||
if (!key || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to update existing setting first
|
||||
const result = await db
|
||||
.update(settings)
|
||||
.set({
|
||||
value: String(value),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
// If no rows were updated, insert new setting
|
||||
if (!result.changes) {
|
||||
await db.insert(settings).values({
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
value: String(value),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// Invalidate app config cache since settings changed
|
||||
invalidateAppConfigCache();
|
||||
|
||||
return NextResponse.json({ message: 'Settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { users, courts, bookings, metrics } from '@/lib/db/schema';
|
||||
import { eq, and, gte, lte, desc, count } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get real stats from database
|
||||
const totalUsers = await db.select({ count: count() }).from(users);
|
||||
const activeCourts = await db.select({ count: count() }).from(courts).where(eq(courts.isActive, true));
|
||||
|
||||
// Get today's bookings count
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todaysBookings = await db
|
||||
.select({ count: count() })
|
||||
.from(bookings)
|
||||
.where(and(eq(bookings.date, today), eq(bookings.status, 'active')));
|
||||
|
||||
// Get current month's bookings from metrics table
|
||||
const currentMonth = new Date().toISOString().substring(0, 7); // "2025-09"
|
||||
const monthlyBookings = await db
|
||||
.select()
|
||||
.from(metrics)
|
||||
.where(and(eq(metrics.metricType, 'monthly_bookings'), eq(metrics.period, currentMonth)))
|
||||
.limit(1);
|
||||
|
||||
// Get recent bookings with user names
|
||||
const recentBookings = await db
|
||||
.select({
|
||||
id: bookings.id,
|
||||
date: bookings.date,
|
||||
startTime: bookings.startTime,
|
||||
endTime: bookings.endTime,
|
||||
courtName: courts.name,
|
||||
userName: users.name,
|
||||
userSurname: users.surname,
|
||||
status: bookings.status,
|
||||
createdAt: bookings.createdAt,
|
||||
})
|
||||
.from(bookings)
|
||||
.leftJoin(users, eq(bookings.userId, users.id))
|
||||
.leftJoin(courts, eq(bookings.courtId, courts.id))
|
||||
.orderBy(desc(bookings.createdAt))
|
||||
.limit(10);
|
||||
|
||||
return NextResponse.json({
|
||||
stats: {
|
||||
totalUsers: totalUsers[0]?.count || 0,
|
||||
activeCourts: activeCourts[0]?.count || 0,
|
||||
todaysBookings: todaysBookings[0]?.count || 0,
|
||||
monthlyBookings: monthlyBookings[0]?.value || 0,
|
||||
},
|
||||
recentBookings: recentBookings.map((booking) => ({
|
||||
...booking,
|
||||
userName: `${booking.userName} ${booking.userSurname}`,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin stats:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { timeSlots } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||
|
||||
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const { dayOfWeek, startTime, endTime, isActive } = await request.json();
|
||||
|
||||
// Check if time slot exists
|
||||
const existingTimeSlot = await db.select().from(timeSlots).where(eq(timeSlots.id, id)).limit(1);
|
||||
if (existingTimeSlot.length === 0) {
|
||||
return NextResponse.json({ error: 'Time slot not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Validate inputs if provided
|
||||
if (dayOfWeek !== undefined && (dayOfWeek < 0 || dayOfWeek > 6)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'dayOfWeek must be between 0 (Sunday) and 6 (Saturday)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||
if (startTime && !timeRegex.test(startTime)) {
|
||||
return NextResponse.json({ error: 'Invalid startTime format. Use HH:MM format' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (endTime && !timeRegex.test(endTime)) {
|
||||
return NextResponse.json({ error: 'Invalid endTime format. Use HH:MM format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatedTimeSlot = await db
|
||||
.update(timeSlots)
|
||||
.set({
|
||||
...(dayOfWeek !== undefined && { dayOfWeek }),
|
||||
...(startTime && { startTime }),
|
||||
...(endTime && { endTime }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(timeSlots.id, id))
|
||||
.returning();
|
||||
|
||||
await logActivity({
|
||||
userId: session.userId,
|
||||
action: ACTIONS.TIME_SLOT_UPDATE,
|
||||
entityType: ENTITY_TYPES.TIME_SLOT,
|
||||
entityId: id,
|
||||
details: { dayOfWeek, startTime, endTime, isActive },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Time slot updated successfully',
|
||||
timeSlot: updatedTimeSlot[0],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating time slot:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
// Check if time slot exists
|
||||
const existingTimeSlot = await db.select().from(timeSlots).where(eq(timeSlots.id, id)).limit(1);
|
||||
if (existingTimeSlot.length === 0) {
|
||||
return NextResponse.json({ error: 'Time slot not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.delete(timeSlots).where(eq(timeSlots.id, id));
|
||||
|
||||
await logActivity({
|
||||
userId: session.userId,
|
||||
action: ACTIONS.TIME_SLOT_DELETE,
|
||||
entityType: ENTITY_TYPES.TIME_SLOT,
|
||||
entityId: id,
|
||||
details: { deleted: existingTimeSlot[0] },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Time slot deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting time slot:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { timeSlots } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const allTimeSlots = await db.select().from(timeSlots).orderBy(timeSlots.dayOfWeek, timeSlots.startTime);
|
||||
|
||||
return NextResponse.json({
|
||||
timeSlots: allTimeSlots,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching time slots:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { dayOfWeek, startTime, endTime, isActive = true } = await request.json();
|
||||
|
||||
if (dayOfWeek === undefined || !startTime || !endTime) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: dayOfWeek, startTime, endTime' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate day of week (0-6)
|
||||
if (dayOfWeek < 0 || dayOfWeek > 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'dayOfWeek must be between 0 (Sunday) and 6 (Saturday)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate time format (HH:MM)
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||
if (!timeRegex.test(startTime) || !timeRegex.test(endTime)) {
|
||||
return NextResponse.json({ error: 'Invalid time format. Use HH:MM format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newTimeSlot = await db
|
||||
.insert(timeSlots)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
dayOfWeek,
|
||||
startTime,
|
||||
endTime,
|
||||
isActive,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
await logActivity({
|
||||
userId: session.userId,
|
||||
action: ACTIONS.TIME_SLOT_CREATE,
|
||||
entityType: ENTITY_TYPES.TIME_SLOT,
|
||||
entityId: newTimeSlot[0].id,
|
||||
details: { dayOfWeek, startTime, endTime },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Time slot created successfully',
|
||||
timeSlot: newTimeSlot[0],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating time slot:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name, surname, email, role, password } = await request.json();
|
||||
const { id } = await context.params;
|
||||
const userId = id;
|
||||
|
||||
if (!name || !surname || !email) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
name,
|
||||
surname,
|
||||
email,
|
||||
role: role || 'user',
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Only hash and update password if provided
|
||||
if (password) {
|
||||
updateData.password = await bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
// Update user
|
||||
const [updatedUser] = await db.update(users).set(updateData).where(eq(users.id, userId)).returning({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({ user: updatedUser, message: 'User updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const userId = id;
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (session.userId === userId) {
|
||||
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete user
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
|
||||
return NextResponse.json({ message: 'User deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users);
|
||||
|
||||
return NextResponse.json({ users: allUsers });
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name, surname, email, password, role } = await request.json();
|
||||
|
||||
if (!name || !surname || !email || !password) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return NextResponse.json({ error: 'User with this email already exists' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
surname,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role: role || 'user',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({ user: newUser, message: 'User created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { announcements } from '@/lib/db/schema';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all active announcements, ordered by creation date (newest first)
|
||||
const allAnnouncements = await db
|
||||
.select({
|
||||
id: announcements.id,
|
||||
title: announcements.title,
|
||||
content: announcements.content,
|
||||
priority: announcements.priority,
|
||||
isActive: announcements.isActive,
|
||||
createdAt: announcements.createdAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.where(eq(announcements.isActive, true))
|
||||
.orderBy(desc(announcements.createdAt));
|
||||
|
||||
return NextResponse.json({
|
||||
announcements: allAnnouncements,
|
||||
unreadCount: allAnnouncements.length, // For now, all announcements are considered unread
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching announcements:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createSession } from '@/lib/session';
|
||||
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
|
||||
if (user.length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await bcrypt.compare(password, user[0].password);
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Create session
|
||||
await createSession({
|
||||
userId: user[0].id,
|
||||
email: user[0].email,
|
||||
role: user[0].role as 'user' | 'admin',
|
||||
});
|
||||
|
||||
// Log the login activity
|
||||
await logActivity({
|
||||
userId: user[0].id,
|
||||
action: ACTIONS.USER_LOGIN,
|
||||
entityType: ENTITY_TYPES.USER,
|
||||
entityId: user[0].id,
|
||||
details: {
|
||||
email: user[0].email,
|
||||
role: user[0].role,
|
||||
},
|
||||
request,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user[0].id,
|
||||
email: user[0].email,
|
||||
name: user[0].name,
|
||||
surname: user[0].surname,
|
||||
role: user[0].role,
|
||||
},
|
||||
message: 'Login successful',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { deleteSession } from '@/lib/session';
|
||||
|
||||
export async function POST() {
|
||||
await deleteSession();
|
||||
return NextResponse.json({ message: 'Logout successful' });
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createSession } from '@/lib/session';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
surname: z.string().min(1),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validatedData = registerSchema.parse(body);
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await db.select().from(users).where(eq(users.email, validatedData.email)).limit(1);
|
||||
if (existingUser.length > 0) {
|
||||
return NextResponse.json({ error: 'User with this email already exists' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(validatedData.password, 10);
|
||||
|
||||
// Create new user
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
email: validatedData.email,
|
||||
name: validatedData.name,
|
||||
surname: validatedData.surname,
|
||||
password: hashedPassword,
|
||||
role: 'user',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create session
|
||||
await createSession({
|
||||
userId: newUser.id,
|
||||
email: newUser.email,
|
||||
role: newUser.role as 'user' | 'admin',
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: newUser.id,
|
||||
email: newUser.email,
|
||||
name: newUser.name,
|
||||
surname: newUser.surname,
|
||||
role: newUser.role,
|
||||
},
|
||||
message: 'User created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input data', details: error.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
console.error('Registration error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { bookings, settings } from '@/lib/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||
|
||||
export async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { notes } = await request.json();
|
||||
const { id } = await context.params;
|
||||
const bookingId = id;
|
||||
|
||||
// Check if booking exists and belongs to user
|
||||
const existingBooking = await db
|
||||
.select()
|
||||
.from(bookings)
|
||||
.where(and(eq(bookings.id, bookingId), eq(bookings.userId, session.userId), eq(bookings.status, 'active')))
|
||||
.limit(1);
|
||||
|
||||
if (existingBooking.length === 0) {
|
||||
return NextResponse.json({ error: 'Booking not found or access denied' }, { status: 404 });
|
||||
}
|
||||
|
||||
const booking = existingBooking[0];
|
||||
|
||||
// Check if booking modifications are allowed
|
||||
const modificationSetting = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, 'allow_booking_modifications'))
|
||||
.limit(1);
|
||||
|
||||
if (modificationSetting.length > 0 && modificationSetting[0].value !== 'true') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Booking modifications are currently disabled by administrator' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the time restriction setting
|
||||
const timeSetting = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, 'booking_modification_hours_before'))
|
||||
.limit(1);
|
||||
|
||||
const requiredHours = timeSetting.length > 0 ? parseFloat(timeSetting[0].value) : 2;
|
||||
|
||||
// Check if booking can be modified (more than required hours before start time)
|
||||
const bookingDateTime = new Date(`${booking.date}T${booking.startTime}`);
|
||||
const now = new Date();
|
||||
const timeDiff = bookingDateTime.getTime() - now.getTime();
|
||||
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff <= requiredHours) {
|
||||
return NextResponse.json(
|
||||
{ error: `Booking can only be modified more than ${requiredHours} hours before the session` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update booking notes
|
||||
await db
|
||||
.update(bookings)
|
||||
.set({
|
||||
notes: notes || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(bookings.id, bookingId));
|
||||
|
||||
// Log the activity
|
||||
await logActivity({
|
||||
userId: session.userId,
|
||||
action: ACTIONS.BOOKING_UPDATE,
|
||||
entityType: ENTITY_TYPES.BOOKING,
|
||||
entityId: bookingId,
|
||||
details: {
|
||||
oldNotes: booking.notes,
|
||||
newNotes: notes,
|
||||
date: booking.date,
|
||||
startTime: booking.startTime,
|
||||
},
|
||||
request,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Booking updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating booking:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const bookingId = id;
|
||||
|
||||
// Check if booking exists and belongs to user
|
||||
const existingBooking = await db
|
||||
.select()
|
||||
.from(bookings)
|
||||
.where(and(eq(bookings.id, bookingId), eq(bookings.userId, session.userId), eq(bookings.status, 'active')))
|
||||
.limit(1);
|
||||
|
||||
if (existingBooking.length === 0) {
|
||||
return NextResponse.json({ error: 'Booking not found or access denied' }, { status: 404 });
|
||||
}
|
||||
|
||||
const booking = existingBooking[0];
|
||||
|
||||
// Check if booking modifications are allowed
|
||||
const modificationSetting = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, 'allow_booking_modifications'))
|
||||
.limit(1);
|
||||
|
||||
if (modificationSetting.length > 0 && modificationSetting[0].value !== 'true') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Booking modifications are currently disabled by administrator' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the time restriction setting
|
||||
const timeSetting = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, 'booking_modification_hours_before'))
|
||||
.limit(1);
|
||||
|
||||
const requiredHours = timeSetting.length > 0 ? parseFloat(timeSetting[0].value) : 2;
|
||||
|
||||
// Check if booking can be cancelled (more than required hours before start time)
|
||||
const bookingDateTime = new Date(`${booking.date}T${booking.startTime}`);
|
||||
const now = new Date();
|
||||
const timeDiff = bookingDateTime.getTime() - now.getTime();
|
||||
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff <= requiredHours) {
|
||||
return NextResponse.json(
|
||||
{ error: `Booking can only be cancelled more than ${requiredHours} hours before the session` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel booking (set status to cancelled)
|
||||
await db
|
||||
.update(bookings)
|
||||
.set({
|
||||
status: 'cancelled',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(bookings.id, bookingId));
|
||||
|
||||
// Log the activity
|
||||
await logActivity({
|
||||
userId: session.userId,
|
||||
action: ACTIONS.BOOKING_CANCEL,
|
||||
entityType: ENTITY_TYPES.BOOKING,
|
||||
entityId: bookingId,
|
||||
details: {
|
||||
date: booking.date,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
courtId: booking.courtId,
|
||||
},
|
||||
request,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Booking cancelled successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cancelling booking:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { bookings, courts, users } from '@/lib/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const date = searchParams.get('date');
|
||||
|
||||
// Build query conditions
|
||||
const whereConditions = [];
|
||||
whereConditions.push(eq(bookings.status, 'active'));
|
||||
|
||||
if (date) {
|
||||
whereConditions.push(eq(bookings.date, date));
|
||||
}
|
||||
|
||||
// Get all active bookings with user and court information
|
||||
const allBookings = await db
|
||||
.select({
|
||||
id: bookings.id,
|
||||
courtId: bookings.courtId,
|
||||
date: bookings.date,
|
||||
startTime: bookings.startTime,
|
||||
endTime: bookings.endTime,
|
||||
status: bookings.status,
|
||||
notes: bookings.notes,
|
||||
createdAt: bookings.createdAt,
|
||||
court: {
|
||||
id: courts.id,
|
||||
name: courts.name,
|
||||
},
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
},
|
||||
})
|
||||
.from(bookings)
|
||||
.innerJoin(courts, eq(bookings.courtId, courts.id))
|
||||
.innerJoin(users, eq(bookings.userId, users.id))
|
||||
.where(whereConditions.length > 1 ? and(...whereConditions) : whereConditions[0]);
|
||||
|
||||
return NextResponse.json({ bookings: allBookings });
|
||||
} catch (error) {
|
||||
console.error('Error fetching all bookings:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { bookings, courts, timeSlots, settings, metrics } from '@/lib/db/schema';
|
||||
import { eq, and, gte, asc } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get today's date to filter out past bookings
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const userBookings = await db
|
||||
.select({
|
||||
id: bookings.id,
|
||||
courtId: bookings.courtId,
|
||||
date: bookings.date,
|
||||
startTime: bookings.startTime,
|
||||
endTime: bookings.endTime,
|
||||
status: bookings.status,
|
||||
notes: bookings.notes,
|
||||
createdAt: bookings.createdAt,
|
||||
court: {
|
||||
id: courts.id,
|
||||
name: courts.name,
|
||||
},
|
||||
})
|
||||
.from(bookings)
|
||||
.innerJoin(courts, eq(bookings.courtId, courts.id))
|
||||
.where(and(eq(bookings.userId, session.userId), eq(bookings.status, 'active'), gte(bookings.date, today)))
|
||||
.orderBy(asc(bookings.date), asc(bookings.startTime));
|
||||
|
||||
return NextResponse.json({ bookings: userBookings });
|
||||
} catch (error) {
|
||||
console.error('Error fetching bookings:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { courtId, date, timeSlot, notes } = await request.json();
|
||||
|
||||
if (!courtId || !date || !timeSlot) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Missing required fields: courtId, date, timeSlot',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse timeSlot (e.g., "14:00") to get start and end times
|
||||
const startTime = timeSlot;
|
||||
const [hours, minutes] = timeSlot.split(':').map(Number);
|
||||
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||
|
||||
// Validate booking date is not in the past
|
||||
const bookingDate = new Date(date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (bookingDate < today) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Cannot book dates in the past',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if court exists and is active
|
||||
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||
if (court.length === 0 || !court[0].isActive) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Court not found or inactive',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// CRITICAL: Validate that booking is allowed for this day and time
|
||||
const dayOfWeek = bookingDate.getDay();
|
||||
const availableTimeSlots = await db
|
||||
.select()
|
||||
.from(timeSlots)
|
||||
.where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
|
||||
|
||||
// Check if any time slots are configured for this day
|
||||
if (availableTimeSlots.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `No bookings are allowed on ${
|
||||
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
|
||||
}s. The facility is closed on this day.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the requested time slot is within any of the allowed time ranges
|
||||
const requestedHour = parseInt(startTime.split(':')[0]);
|
||||
const isTimeSlotValid = availableTimeSlots.some((slot) => {
|
||||
const slotStartHour = parseInt(slot.startTime.split(':')[0]);
|
||||
const slotEndHour = parseInt(slot.endTime.split(':')[0]);
|
||||
return requestedHour >= slotStartHour && requestedHour < slotEndHour;
|
||||
});
|
||||
|
||||
if (!isTimeSlotValid) {
|
||||
const allowedRanges = availableTimeSlots.map((slot) => `${slot.startTime}-${slot.endTime}`).join(', ');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Time slot ${startTime} is not available on ${
|
||||
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
|
||||
}s. Available times: ${allowedRanges}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check booking restrictions per user per hour per day
|
||||
const maxBookingsSetting = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day'))
|
||||
.limit(1);
|
||||
|
||||
const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1; // Default to 1 if setting not found
|
||||
|
||||
// Count user's existing bookings for this hour on this day
|
||||
const userBookingsThisHour = await db
|
||||
.select()
|
||||
.from(bookings)
|
||||
.where(
|
||||
and(
|
||||
eq(bookings.userId, session.userId),
|
||||
eq(bookings.date, date),
|
||||
eq(bookings.startTime, startTime),
|
||||
eq(bookings.status, 'active')
|
||||
)
|
||||
);
|
||||
|
||||
if (userBookingsThisHour.length >= maxBookingsPerHour) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `You have reached the maximum limit of ${maxBookingsPerHour} booking(s) per hour. You already have ${userBookingsThisHour.length} booking(s) at ${startTime} on this date.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if slot is already booked
|
||||
const existingBooking = await db
|
||||
.select()
|
||||
.from(bookings)
|
||||
.where(
|
||||
and(
|
||||
eq(bookings.courtId, courtId),
|
||||
eq(bookings.date, date),
|
||||
eq(bookings.startTime, startTime),
|
||||
eq(bookings.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingBooking.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Time slot already booked',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create the booking
|
||||
const [newBooking] = await db
|
||||
.insert(bookings)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: session.userId,
|
||||
courtId,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
status: 'active',
|
||||
notes: notes || null, // Include notes from the request
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Log the activity
|
||||
await logActivity({
|
||||
userId: session.userId,
|
||||
action: ACTIONS.BOOKING_CREATE,
|
||||
entityType: ENTITY_TYPES.BOOKING,
|
||||
entityId: newBooking.id,
|
||||
details: {
|
||||
courtId,
|
||||
courtName: court[0].name,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
request,
|
||||
});
|
||||
|
||||
// Update monthly metrics
|
||||
const currentMonth = new Date().toISOString().substring(0, 7); // "2025-09"
|
||||
try {
|
||||
const existingMetric = await db
|
||||
.select()
|
||||
.from(metrics)
|
||||
.where(and(eq(metrics.metricType, 'monthly_bookings'), eq(metrics.period, currentMonth)))
|
||||
.limit(1);
|
||||
|
||||
if (existingMetric.length > 0) {
|
||||
// Increment existing metric
|
||||
await db
|
||||
.update(metrics)
|
||||
.set({
|
||||
value: existingMetric[0].value + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(metrics.id, existingMetric[0].id));
|
||||
} else {
|
||||
// Create new metric for this month
|
||||
await db.insert(metrics).values({
|
||||
id: crypto.randomUUID(),
|
||||
metricType: 'monthly_bookings',
|
||||
period: currentMonth,
|
||||
value: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating monthly metrics:', error);
|
||||
// Don't fail the booking if metrics update fails
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
booking: newBooking,
|
||||
message: 'Booking created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating booking:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAppConfig } from '@/lib/app-config';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = await getAppConfig();
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error('Error fetching app config:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { courts } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all active courts (users can read courts)
|
||||
const activeCourts = await db.select().from(courts).where(eq(courts.isActive, true));
|
||||
|
||||
return NextResponse.json({
|
||||
courts: activeCourts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching courts:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifySession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { bookings, users, courts } from '@/lib/db/schema';
|
||||
import { eq, and, desc, gte } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await verifySession();
|
||||
if (!session.isAuth || !session.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
|
||||
// Get recent bookings for the current user with court and user details
|
||||
const recentBookings = await db
|
||||
.select({
|
||||
id: bookings.id,
|
||||
date: bookings.date,
|
||||
startTime: bookings.startTime,
|
||||
endTime: bookings.endTime,
|
||||
status: bookings.status,
|
||||
notes: bookings.notes,
|
||||
createdAt: bookings.createdAt,
|
||||
court: {
|
||||
id: courts.id,
|
||||
name: courts.name,
|
||||
},
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
email: users.email,
|
||||
},
|
||||
})
|
||||
.from(bookings)
|
||||
.innerJoin(courts, eq(bookings.courtId, courts.id))
|
||||
.innerJoin(users, eq(bookings.userId, users.id))
|
||||
.where(
|
||||
and(eq(bookings.userId, session.userId), eq(bookings.status, 'active'), gte(bookings.date, todayStr))
|
||||
)
|
||||
.orderBy(desc(bookings.createdAt))
|
||||
.limit(5);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
bookings: recentBookings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent bookings:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch recent bookings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { users, bookings, courts } from '@/lib/db/schema';
|
||||
import { eq, count, and, gte } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get current date
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
|
||||
// Get total users count
|
||||
const [totalUsersResult] = await db.select({ count: count() }).from(users);
|
||||
|
||||
// Get today's bookings count
|
||||
const [todayBookingsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(bookings)
|
||||
.where(and(eq(bookings.date, todayStr), eq(bookings.status, 'active')));
|
||||
|
||||
// Get active courts count
|
||||
const [activeCourtsResult] = await db.select({ count: count() }).from(courts).where(eq(courts.isActive, true));
|
||||
|
||||
// Get user's total bookings
|
||||
const [userBookingsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(bookings)
|
||||
.where(and(eq(bookings.userId, session.userId), eq(bookings.status, 'active')));
|
||||
|
||||
// Get user's upcoming bookings (today and future)
|
||||
const [upcomingBookingsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(bookings)
|
||||
.where(
|
||||
and(eq(bookings.userId, session.userId), gte(bookings.date, todayStr), eq(bookings.status, 'active'))
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
stats: {
|
||||
totalUsers: totalUsersResult.count,
|
||||
todayBookings: todayBookingsResult.count,
|
||||
activeCourts: activeCourtsResult.count,
|
||||
userBookings: userBookingsResult.count,
|
||||
upcomingBookings: upcomingBookingsResult.count,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard stats:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { settings } from '@/lib/db/schema';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test database connectivity
|
||||
const startTime = Date.now();
|
||||
await db.select().from(settings).limit(1);
|
||||
const dbLatency = Date.now() - startTime;
|
||||
|
||||
// Check memory usage
|
||||
const memoryUsage = process.memoryUsage();
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
environment: process.env.NODE_ENV,
|
||||
database: {
|
||||
status: 'connected',
|
||||
latency: `${dbLatency}ms`,
|
||||
},
|
||||
memory: {
|
||||
rss: `${Math.round(memoryUsage.rss / 1024 / 1024)}MB`,
|
||||
heapUsed: `${Math.round(memoryUsage.heapUsed / 1024 / 1024)}MB`,
|
||||
heapTotal: `${Math.round(memoryUsage.heapTotal / 1024 / 1024)}MB`,
|
||||
},
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Database connection failed',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { settings } from '@/lib/db/schema';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all settings (users can read settings but not modify them)
|
||||
const allSettings = await db.select().from(settings);
|
||||
|
||||
return NextResponse.json({
|
||||
settings: allSettings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { timeSlots } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all active time slots
|
||||
const allTimeSlots = await db
|
||||
.select()
|
||||
.from(timeSlots)
|
||||
.where(eq(timeSlots.isActive, true))
|
||||
.orderBy(timeSlots.dayOfWeek, timeSlots.startTime);
|
||||
|
||||
return NextResponse.json({
|
||||
timeSlots: allTimeSlots,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching time slots:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user profile
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
role: users.role,
|
||||
themePreference: users.themePreference,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
...user,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name, surname } = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !surname) {
|
||||
return NextResponse.json({ error: 'Name and surname are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get current user data for logging
|
||||
const [currentUser] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Update user profile
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
name: name.trim(),
|
||||
surname: surname.trim(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, session.userId));
|
||||
|
||||
// Log the activity
|
||||
await logActivity({
|
||||
userId: session.userId,
|
||||
action: ACTIONS.USER_UPDATE,
|
||||
entityType: ENTITY_TYPES.USER,
|
||||
entityId: session.userId,
|
||||
details: {
|
||||
previousData: {
|
||||
name: currentUser.name,
|
||||
surname: currentUser.surname,
|
||||
},
|
||||
newData: {
|
||||
name: name.trim(),
|
||||
surname: surname.trim(),
|
||||
},
|
||||
},
|
||||
request,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Profile updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user profile:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await db
|
||||
.select({
|
||||
themePreference: users.themePreference,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
if (user.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
themePreference: user[0].themePreference,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching theme preference:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { themePreference } = await request.json();
|
||||
|
||||
if (!themePreference || !['light', 'dark', 'system'].includes(themePreference)) {
|
||||
return NextResponse.json({ error: 'Invalid theme preference' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
themePreference,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, session.userId));
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Theme preference updated successfully',
|
||||
themePreference,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating theme preference:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSession } from '@/lib/session';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
|
||||
import { EnhancedBookingCalendar } from '@/components/booking/enhanced-booking-calendar';
|
||||
import { UserBookingManagement } from '@/components/booking/user-booking-management';
|
||||
import { getAppConfig } from '@/lib/app-config';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const config = await getAppConfig();
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
// Get full user information
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
surname: users.surname,
|
||||
role: users.role,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const userWithSession = {
|
||||
...session,
|
||||
name: user.name,
|
||||
surname: user.surname,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-background'>
|
||||
<DashboardHeader user={userWithSession} />
|
||||
|
||||
<main className='container mx-auto px-4 py-8'>
|
||||
<div className='grid gap-8 lg:grid-cols-3'>
|
||||
{/* Main Content */}
|
||||
<div className='lg:col-span-2 space-y-6'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold text-foreground mb-2'>
|
||||
Welcome back,{' '}
|
||||
{user.name && user.surname ? `${user.name} ${user.surname}` : user.email.split('@')[0]}!
|
||||
🏓
|
||||
</h1>
|
||||
<p className='text-muted-foreground'>
|
||||
Book your {config.sportName.toLowerCase()} court and enjoy your game
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EnhancedBookingCalendar />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className='space-y-6'>
|
||||
<UserBookingManagement />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 98.8235%;
|
||||
--foreground: 0 0% 9.0196%;
|
||||
--card: 0 0% 98.8235%;
|
||||
--card-foreground: 0 0% 9.0196%;
|
||||
--popover: 0 0% 98.8235%;
|
||||
--popover-foreground: 0 0% 32.1569%;
|
||||
--primary: 151.3274 66.8639% 66.8627%;
|
||||
--primary-foreground: 153.3333 13.0435% 13.5294%;
|
||||
--secondary: 0 0% 99.2157%;
|
||||
--secondary-foreground: 0 0% 9.0196%;
|
||||
--muted: 0 0% 92.9412%;
|
||||
--muted-foreground: 0 0% 12.549%;
|
||||
--accent: 0 0% 92.9412%;
|
||||
--accent-foreground: 0 0% 12.549%;
|
||||
--destructive: 9.8901 81.982% 43.5294%;
|
||||
--destructive-foreground: 0 100% 99.4118%;
|
||||
--border: 0 0% 87.451%;
|
||||
--input: 0 0% 96.4706%;
|
||||
--ring: 151.3274 66.8639% 66.8627%;
|
||||
--chart-1: 151.3274 66.8639% 66.8627%;
|
||||
--chart-2: 217.2193 91.2195% 59.8039%;
|
||||
--chart-3: 258.3117 89.5349% 66.2745%;
|
||||
--chart-4: 37.6923 92.126% 50.1961%;
|
||||
--chart-5: 160.1183 84.0796% 39.4118%;
|
||||
--sidebar: 0 0% 98.8235%;
|
||||
--sidebar-foreground: 0 0% 43.9216%;
|
||||
--sidebar-primary: 151.3274 66.8639% 66.8627%;
|
||||
--sidebar-primary-foreground: 153.3333 13.0435% 13.5294%;
|
||||
--sidebar-accent: 0 0% 92.9412%;
|
||||
--sidebar-accent-foreground: 0 0% 12.549%;
|
||||
--sidebar-border: 0 0% 87.451%;
|
||||
--sidebar-ring: 151.3274 66.8639% 66.8627%;
|
||||
--font-sans: Outfit, sans-serif;
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono: monospace;
|
||||
--radius: 0.5rem;
|
||||
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
|
||||
--tracking-normal: 0.025em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 7.0588%;
|
||||
--foreground: 214.2857 31.8182% 91.3725%;
|
||||
--card: 0 0% 9.0196%;
|
||||
--card-foreground: 214.2857 31.8182% 91.3725%;
|
||||
--popover: 0 0% 14.1176%;
|
||||
--popover-foreground: 0 0% 66.2745%;
|
||||
--primary: 154.898 100% 19.2157%;
|
||||
--primary-foreground: 152.7273 19.2982% 88.8235%;
|
||||
--secondary: 0 0% 14.1176%;
|
||||
--secondary-foreground: 0 0% 98.0392%;
|
||||
--muted: 0 0% 12.1569%;
|
||||
--muted-foreground: 0 0% 63.5294%;
|
||||
--accent: 0 0% 19.2157%;
|
||||
--accent-foreground: 0 0% 98.0392%;
|
||||
--destructive: 6.6667 60% 20.5882%;
|
||||
--destructive-foreground: 12 12.1951% 91.9608%;
|
||||
--border: 0 0% 16.0784%;
|
||||
--input: 0 0% 14.1176%;
|
||||
--ring: 141.8919 69.1589% 58.0392%;
|
||||
--chart-1: 141.8919 69.1589% 58.0392%;
|
||||
--chart-2: 213.1169 93.9024% 67.8431%;
|
||||
--chart-3: 255.1351 91.7355% 76.2745%;
|
||||
--chart-4: 43.2558 96.4126% 56.2745%;
|
||||
--chart-5: 172.4551 66.0079% 50.3922%;
|
||||
--sidebar: 0 0% 7.0588%;
|
||||
--sidebar-foreground: 0 0% 53.7255%;
|
||||
--sidebar-primary: 154.898 100% 19.2157%;
|
||||
--sidebar-primary-foreground: 152.7273 19.2982% 88.8235%;
|
||||
--sidebar-accent: 0 0% 19.2157%;
|
||||
--sidebar-accent-foreground: 0 0% 98.0392%;
|
||||
--sidebar-border: 0 0% 16.0784%;
|
||||
--sidebar-ring: 141.8919 69.1589% 58.0392%;
|
||||
--font-sans: Outfit, sans-serif;
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono: monospace;
|
||||
--radius: 0.5rem;
|
||||
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
|
||||
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { getAppConfig } from '@/lib/app-config';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = await getAppConfig();
|
||||
|
||||
return {
|
||||
title: config.appTitle,
|
||||
description: config.appDescription,
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider attribute='class' defaultTheme='system' enableSystem disableTransitionOnChange>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { LoginForm } from '@/components/auth/LoginForm';
|
||||
import { getAppConfig } from '@/lib/app-config';
|
||||
|
||||
export default async function LoginPage() {
|
||||
const config = await getAppConfig();
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center px-4'>
|
||||
<div className='w-full max-w-md space-y-6'>
|
||||
<div className='text-center'>
|
||||
<h1 className='text-3xl font-bold text-foreground mb-2'>🏓 {config.clubName}</h1>
|
||||
<p className='text-muted-foreground'>{config.appDescription}</p>
|
||||
</div>
|
||||
|
||||
<LoginForm />
|
||||
|
||||
<div className='text-center text-sm'>
|
||||
<span className='text-muted-foreground'>Don't have an account? </span>
|
||||
<Link href='/register' className='text-primary hover:text-primary/80 font-medium'>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (session) {
|
||||
if (session.role === 'admin') {
|
||||
redirect('/admin');
|
||||
} else {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
} else {
|
||||
redirect('/login');
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { RegisterForm } from '@/components/auth/RegisterForm';
|
||||
import { getAppConfig } from '@/lib/app-config';
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const config = await getAppConfig();
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center px-4'>
|
||||
<div className='w-full max-w-md space-y-6'>
|
||||
<div className='text-center'>
|
||||
<h1 className='text-3xl font-bold text-foreground mb-2'>🏓 {config.clubName}</h1>
|
||||
<p className='text-muted-foreground'>Join our {config.sportName.toLowerCase()} community</p>
|
||||
</div>
|
||||
|
||||
<RegisterForm />
|
||||
|
||||
<div className='text-center text-sm'>
|
||||
<span className='text-muted-foreground'>Already have an account? </span>
|
||||
<Link href='/login' className='text-primary hover:text-primary/80 font-medium'>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# Cloudflare Tunnel Configuration for LCC Table Tennis Booking
|
||||
# Domain: lcc-tt-booking.mikicvi.com
|
||||
|
||||
# Save this as ~/.cloudflared/config.yml after setting up your tunnel
|
||||
|
||||
tunnel: <your-tunnel-id>
|
||||
credentials-file: /home/pi/.cloudflared/<your-tunnel-id>.json
|
||||
|
||||
# Ingress rules
|
||||
ingress:
|
||||
# Main application
|
||||
- hostname: lcc-tt-booking.mikicvi.com
|
||||
service: http://localhost:3000
|
||||
originRequest:
|
||||
# Enable HTTP/2
|
||||
httpHostHeader: lcc-tt-booking.mikicvi.com
|
||||
# Connection settings
|
||||
connectTimeout: 30s
|
||||
tlsTimeout: 10s
|
||||
# Health checks
|
||||
proxyType: http
|
||||
# Disable chunked encoding for better compatibility
|
||||
disableChunkedEncoding: true
|
||||
|
||||
# Health check endpoint (optional, for monitoring)
|
||||
- hostname: health.lcc-tt-booking.mikicvi.com
|
||||
service: http://localhost:3000/api/health
|
||||
|
||||
# Catch-all rule (must be last)
|
||||
- service: http_status:404
|
||||
|
||||
# Optional: Logging configuration
|
||||
loglevel: info
|
||||
transport-loglevel: warn
|
||||
|
||||
# Optional: Metrics
|
||||
metrics: 0.0.0.0:2000
|
||||
|
||||
# Optional: Enable compression
|
||||
compression: gzip
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Megaphone, Plus, Edit, Trash2, Calendar, AlertCircle, CheckCircle, Clock } from 'lucide-react';
|
||||
|
||||
interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
isActive: boolean;
|
||||
expiresAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface AnnouncementFormData {
|
||||
title: string;
|
||||
content: string;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
isActive: boolean;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export function AdminAnnouncementManagement() {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingAnnouncement, setEditingAnnouncement] = useState<Announcement | null>(null);
|
||||
const [announcementToDelete, setAnnouncementToDelete] = useState<Announcement | null>(null);
|
||||
const [formData, setFormData] = useState<AnnouncementFormData>({
|
||||
title: '',
|
||||
content: '',
|
||||
priority: 'medium',
|
||||
isActive: true,
|
||||
expiresAt: '',
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnnouncements();
|
||||
}, []);
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/announcements');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAnnouncements(data.announcements);
|
||||
} else {
|
||||
throw new Error('Failed to fetch announcements');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching announcements:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch announcements',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAnnouncement = async () => {
|
||||
try {
|
||||
if (!formData.title || !formData.content) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please fill in title and content',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
expiresAt: formData.expiresAt || null,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/admin/announcements', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Announcement created successfully',
|
||||
});
|
||||
setIsCreateDialogOpen(false);
|
||||
resetForm();
|
||||
fetchAnnouncements();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to create announcement',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating announcement:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to create announcement',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditAnnouncement = async () => {
|
||||
try {
|
||||
if (!editingAnnouncement || !formData.title || !formData.content) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please fill in title and content',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
expiresAt: formData.expiresAt || null,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/admin/announcements/${editingAnnouncement.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Announcement updated successfully',
|
||||
});
|
||||
setIsEditDialogOpen(false);
|
||||
setEditingAnnouncement(null);
|
||||
resetForm();
|
||||
fetchAnnouncements();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to update announcement',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating announcement:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update announcement',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (announcement: Announcement) => {
|
||||
setAnnouncementToDelete(announcement);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteAnnouncement = async () => {
|
||||
if (announcementToDelete) {
|
||||
await handleDeleteAnnouncement(announcementToDelete.id);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setAnnouncementToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAnnouncement = async (announcementId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/announcements/${announcementId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Announcement deleted successfully',
|
||||
});
|
||||
fetchAnnouncements();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to delete announcement',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting announcement:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete announcement',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (announcement: Announcement) => {
|
||||
setEditingAnnouncement(announcement);
|
||||
setFormData({
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
priority: announcement.priority,
|
||||
isActive: announcement.isActive,
|
||||
expiresAt: announcement.expiresAt ? announcement.expiresAt.split('T')[0] : '',
|
||||
});
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
content: '',
|
||||
priority: 'medium',
|
||||
isActive: true,
|
||||
expiresAt: '',
|
||||
});
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'text-destructive bg-destructive/10 dark:bg-destructive/20';
|
||||
case 'medium':
|
||||
return 'text-amber-600 bg-amber-50 dark:text-amber-400 dark:bg-amber-950/50';
|
||||
case 'low':
|
||||
return 'text-green-600 bg-green-50 dark:text-green-400 dark:bg-green-950/50';
|
||||
default:
|
||||
return 'text-muted-foreground bg-muted';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return <AlertCircle className='h-4 w-4' />;
|
||||
case 'medium':
|
||||
return <Clock className='h-4 w-4' />;
|
||||
case 'low':
|
||||
return <CheckCircle className='h-4 w-4' />;
|
||||
default:
|
||||
return <CheckCircle className='h-4 w-4' />;
|
||||
}
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt?: string) => {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className='flex justify-center items-center py-8'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Megaphone className='h-6 w-6' />
|
||||
<h2 className='text-2xl font-bold'>Announcement Management</h2>
|
||||
</div>
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => resetForm()}>
|
||||
<Plus className='h-4 w-4 mr-2' />
|
||||
Create Announcement
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Announcement</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='title'>Title</Label>
|
||||
<Input
|
||||
id='title'
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder='Announcement title'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='content'>Content</Label>
|
||||
<Textarea
|
||||
id='content'
|
||||
value={formData.content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setFormData({ ...formData, content: e.target.value })
|
||||
}
|
||||
placeholder='Announcement content'
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Label htmlFor='priority'>Priority</Label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value: 'low' | 'medium' | 'high') =>
|
||||
setFormData({ ...formData, priority: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select priority' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='low'>Low</SelectItem>
|
||||
<SelectItem value='medium'>Medium</SelectItem>
|
||||
<SelectItem value='high'>High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='expiresAt'>Expires On (Optional)</Label>
|
||||
<Input
|
||||
id='expiresAt'
|
||||
type='date'
|
||||
value={formData.expiresAt}
|
||||
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='isActive'
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className='rounded'
|
||||
/>
|
||||
<Label htmlFor='isActive'>Active (visible to users)</Label>
|
||||
</div>
|
||||
<div className='flex gap-2 justify-end'>
|
||||
<Button variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateAnnouncement}>Create Announcement</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Announcements Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Announcements ({announcements.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{announcements.map((announcement) => (
|
||||
<TableRow key={announcement.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className='font-medium text-foreground'>{announcement.title}</div>
|
||||
<div className='text-sm text-muted-foreground truncate max-w-xs'>
|
||||
{announcement.content}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getPriorityColor(announcement.priority)}>
|
||||
<div className='flex items-center gap-1'>
|
||||
{getPriorityIcon(announcement.priority)}
|
||||
{announcement.priority}
|
||||
</div>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
isExpired(announcement.expiresAt)
|
||||
? 'destructive'
|
||||
: announcement.isActive
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{isExpired(announcement.expiresAt)
|
||||
? 'Expired'
|
||||
: announcement.isActive
|
||||
? 'Active'
|
||||
: 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4 text-muted-foreground' />
|
||||
{announcement.expiresAt
|
||||
? new Date(announcement.expiresAt).toLocaleDateString('en-IE')
|
||||
: 'Never'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4 text-muted-foreground' />
|
||||
{new Date(announcement.createdAt).toLocaleDateString('en-IE')}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => openEditDialog(announcement)}
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => openDeleteDialog(announcement)}
|
||||
className='text-destructive hover:text-destructive/90'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{announcements.length === 0 && (
|
||||
<div className='text-center py-8 text-muted-foreground'>
|
||||
No announcements found. Create your first announcement!
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Announcement Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Announcement</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='edit-title'>Title</Label>
|
||||
<Input
|
||||
id='edit-title'
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder='Announcement title'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='edit-content'>Content</Label>
|
||||
<Textarea
|
||||
id='edit-content'
|
||||
value={formData.content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setFormData({ ...formData, content: e.target.value })
|
||||
}
|
||||
placeholder='Announcement content'
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Label htmlFor='edit-priority'>Priority</Label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value: 'low' | 'medium' | 'high') =>
|
||||
setFormData({ ...formData, priority: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select priority' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='low'>Low</SelectItem>
|
||||
<SelectItem value='medium'>Medium</SelectItem>
|
||||
<SelectItem value='high'>High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='edit-expiresAt'>Expires On (Optional)</Label>
|
||||
<Input
|
||||
id='edit-expiresAt'
|
||||
type='date'
|
||||
value={formData.expiresAt}
|
||||
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='edit-isActive'
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className='rounded'
|
||||
/>
|
||||
<Label htmlFor='edit-isActive'>Active (visible to users)</Label>
|
||||
</div>
|
||||
<div className='flex gap-2 justify-end'>
|
||||
<Button variant='outline' onClick={() => setIsEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleEditAnnouncement}>Update Announcement</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{' '}
|
||||
{announcementToDelete ? `"${announcementToDelete.title}"` : 'this announcement'}? This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteAnnouncement}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Court {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface CourtFormData {
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function AdminCourtManagement() {
|
||||
const [courts, setCourts] = useState<Court[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
|
||||
const [courtToDelete, setCourtToDelete] = useState<Court | null>(null);
|
||||
const [formData, setFormData] = useState<CourtFormData>({
|
||||
name: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourts();
|
||||
}, []);
|
||||
|
||||
const fetchCourts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/courts');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCourts(data.courts || []);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch courts',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching courts:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch courts',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Court name is required',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingCourt) {
|
||||
setEditing(editingCourt.id);
|
||||
const response = await fetch(`/api/admin/courts/${editingCourt.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Court updated successfully',
|
||||
});
|
||||
await fetchCourts();
|
||||
resetForm();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.error || 'Failed to update court',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setCreating(true);
|
||||
const response = await fetch('/api/admin/courts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Court created successfully',
|
||||
});
|
||||
await fetchCourts();
|
||||
resetForm();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.error || 'Failed to create court',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving court:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to save court',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (court: Court) => {
|
||||
setEditingCourt(court);
|
||||
setFormData({
|
||||
name: court.name,
|
||||
isActive: court.isActive,
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const openDeleteDialog = (court: Court) => {
|
||||
setCourtToDelete(court);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteCourt = async () => {
|
||||
if (courtToDelete) {
|
||||
await handleDelete(courtToDelete.id);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setCourtToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (courtId: string) => {
|
||||
try {
|
||||
setDeleting(courtId);
|
||||
const response = await fetch(`/api/admin/courts/${courtId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Court deleted successfully',
|
||||
});
|
||||
await fetchCourts();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.error || 'Failed to delete court',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting court:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete court',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({ name: '', isActive: true });
|
||||
setEditingCourt(null);
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Court Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
||||
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Settings className='h-5 w-5' />
|
||||
Court Management
|
||||
</CardTitle>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' variant='outline' onClick={fetchCourts}>
|
||||
<RefreshCw className='h-4 w-4 mr-2' />
|
||||
Refresh
|
||||
</Button>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size='sm' onClick={() => setEditingCourt(null)}>
|
||||
<Plus className='h-4 w-4 mr-2' />
|
||||
Add Court
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingCourt ? 'Edit Court' : 'Create New Court'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='name'>Court Name</Label>
|
||||
<Input
|
||||
id='name'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder='e.g., Court 1, Main Court'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Switch
|
||||
id='isActive'
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormData({ ...formData, isActive: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor='isActive'>Active (available for booking)</Label>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={creating || Boolean(editing)}>
|
||||
{creating || editing ? (
|
||||
<>
|
||||
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
||||
{editingCourt ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : editingCourt ? (
|
||||
'Update Court'
|
||||
) : (
|
||||
'Create Court'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{courts.length === 0 ? (
|
||||
<div className='text-center py-8 text-gray-500'>
|
||||
<MapPin className='h-12 w-12 mx-auto mb-4 text-gray-300' />
|
||||
<p>No courts found. Create your first court to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{courts.map((court) => (
|
||||
<div key={court.id} className='border rounded-lg p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<MapPin className='h-5 w-5 text-blue-600' />
|
||||
<div>
|
||||
<h3 className='font-medium'>{court.name}</h3>
|
||||
<p className='text-sm text-gray-500'>
|
||||
Created {new Date(court.createdAt).toLocaleDateString('en-IE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<Badge variant={court.isActive ? 'default' : 'secondary'}>
|
||||
{court.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleEdit(court)}
|
||||
disabled={editing === court.id}
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => openDeleteDialog(court)}
|
||||
disabled={deleting === court.id}
|
||||
className='text-red-600 hover:text-red-700'
|
||||
>
|
||||
{deleting === court.id ? (
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteCourt}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, User, Clock, Globe } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface ActivityLog {
|
||||
id: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
details?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
createdAt: Date;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function AdminLogs() {
|
||||
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/logs?limit=50');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLogs(data.logs || []);
|
||||
} else {
|
||||
console.error('Failed to fetch logs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getActionBadgeColor = (action: string) => {
|
||||
switch (action.toLowerCase()) {
|
||||
case 'create':
|
||||
case 'created':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'update':
|
||||
case 'updated':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'delete':
|
||||
case 'deleted':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'login':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'logout':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const formatUserAgent = (userAgent?: string) => {
|
||||
if (!userAgent) return 'Unknown';
|
||||
if (userAgent.includes('Chrome')) return 'Chrome';
|
||||
if (userAgent.includes('Firefox')) return 'Firefox';
|
||||
if (userAgent.includes('Safari')) return 'Safari';
|
||||
if (userAgent.includes('Edge')) return 'Edge';
|
||||
return 'Unknown Browser';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle>Activity Logs</CardTitle>
|
||||
<Button size='sm' disabled>
|
||||
<RefreshCw className='h-4 w-4 animate-spin mr-2' />
|
||||
Loading...
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
||||
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle>Activity Logs</CardTitle>
|
||||
<Button size='sm' onClick={fetchLogs}>
|
||||
<RefreshCw className='h-4 w-4 mr-2' />
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logs.length === 0 ? (
|
||||
<div className='text-center py-8 text-gray-500'>No activity logs found.</div>
|
||||
) : (
|
||||
<div className='space-y-4 max-h-96 overflow-y-auto'>
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className='border rounded-lg p-4 space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Badge className={getActionBadgeColor(log.action)}>{log.action}</Badge>
|
||||
<span className='text-sm font-medium'>
|
||||
{log.entityType}
|
||||
{log.entityId && (
|
||||
<span className='text-gray-500 ml-1'>
|
||||
({log.entityId.substring(0, 8)}...)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs text-gray-500'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{format(new Date(log.createdAt), 'MMM dd, HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<User className='h-4 w-4 text-gray-400' />
|
||||
{log.user ? (
|
||||
<span>
|
||||
{log.user.name} {log.user.surname} ({log.user.email})
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-gray-500'>System/Anonymous</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.ipAddress && (
|
||||
<div className='flex items-center gap-2 text-xs text-gray-500'>
|
||||
<Globe className='h-3 w-3' />
|
||||
<span>{log.ipAddress}</span>
|
||||
<span>•</span>
|
||||
<span>{formatUserAgent(log.userAgent)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.details && (
|
||||
<div className='text-xs text-gray-600 bg-gray-50 rounded p-2'>
|
||||
<pre className='whitespace-pre-wrap break-words'>
|
||||
{JSON.stringify(JSON.parse(log.details), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Calendar, Clock, MapPin } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Booking {
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
court: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminRecentBookings() {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
}, []);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/recent-bookings?limit=5');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBookings(data.bookings || []);
|
||||
} else {
|
||||
console.error('Failed to fetch bookings');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching bookings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return format(date, 'MMM dd');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle>Recent Bookings</CardTitle>
|
||||
<Button size='sm' disabled>
|
||||
<RefreshCw className='h-4 w-4 animate-spin mr-2' />
|
||||
Loading...
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
||||
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle>Recent Bookings</CardTitle>
|
||||
<Button size='sm' onClick={fetchBookings}>
|
||||
<RefreshCw className='h-4 w-4 mr-2' />
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bookings.length === 0 ? (
|
||||
<div className='text-center py-6 text-gray-500'>No recent bookings found.</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{bookings.map((booking) => (
|
||||
<div key={booking.id} className='border rounded-lg p-4 space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<p className='font-medium'>
|
||||
{booking.user.name} {booking.user.surname}
|
||||
</p>
|
||||
<p className='text-sm text-gray-500'>{booking.user.email}</p>
|
||||
</div>
|
||||
<Badge className={getStatusBadgeColor(booking.status)}>{booking.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-4 text-sm text-gray-600'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<MapPin className='h-4 w-4' />
|
||||
<span>{booking.court.name}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
<span>{formatDate(booking.date)}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Clock className='h-4 w-4' />
|
||||
<span>
|
||||
{booking.startTime} - {booking.endTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.notes && (
|
||||
<p className='text-sm text-gray-600 bg-gray-50 rounded p-2'>{booking.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Settings, Save, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Setting {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface SettingsData {
|
||||
// Club/Brand Settings
|
||||
club_name: string;
|
||||
sport_name: string;
|
||||
app_title: string;
|
||||
app_description: string;
|
||||
// Booking Settings
|
||||
booking_window_days: string;
|
||||
max_booking_duration_hours: string;
|
||||
min_booking_duration_minutes: string;
|
||||
booking_start_time: string;
|
||||
booking_end_time: string;
|
||||
allow_weekend_bookings: string;
|
||||
max_bookings_per_user_per_hour_per_day: string;
|
||||
allow_booking_modifications: string;
|
||||
booking_modification_hours_before: string;
|
||||
}
|
||||
|
||||
export function AdminSettingsManagement() {
|
||||
const [settings, setSettings] = useState<SettingsData>({
|
||||
// Club/Brand Settings
|
||||
club_name: 'TT Club',
|
||||
sport_name: 'Table Tennis',
|
||||
app_title: 'Table Tennis Booking System',
|
||||
app_description: 'Book your table tennis court slots with ease',
|
||||
// Booking Settings
|
||||
booking_window_days: '7',
|
||||
max_booking_duration_hours: '2',
|
||||
min_booking_duration_minutes: '30',
|
||||
booking_start_time: '08:00',
|
||||
booking_end_time: '22:00',
|
||||
allow_weekend_bookings: 'true',
|
||||
max_bookings_per_user_per_hour_per_day: '1',
|
||||
allow_booking_modifications: 'true',
|
||||
booking_modification_hours_before: '1',
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const settingsMap: SettingsData = {
|
||||
// Club/Brand Settings
|
||||
club_name: 'TT Club',
|
||||
sport_name: 'Table Tennis',
|
||||
app_title: 'Table Tennis Booking System',
|
||||
app_description: 'Book your table tennis court slots with ease',
|
||||
// Booking Settings
|
||||
booking_window_days: '7',
|
||||
max_booking_duration_hours: '2',
|
||||
min_booking_duration_minutes: '30',
|
||||
booking_start_time: '08:00',
|
||||
booking_end_time: '22:00',
|
||||
allow_weekend_bookings: 'true',
|
||||
max_bookings_per_user_per_hour_per_day: '1',
|
||||
allow_booking_modifications: 'true',
|
||||
booking_modification_hours_before: '1',
|
||||
}; // Map the settings array to our object
|
||||
data.settings?.forEach((setting: Setting) => {
|
||||
if (setting.key in settingsMap) {
|
||||
settingsMap[setting.key as keyof SettingsData] = setting.value;
|
||||
}
|
||||
});
|
||||
|
||||
setSettings(settingsMap);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch settings',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch settings',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Convert settings object to array format
|
||||
const settingsArray = Object.entries(settings).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: settingsArray }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Settings updated successfully',
|
||||
});
|
||||
await fetchSettings();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.error || 'Failed to update settings',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update settings',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = (key: keyof SettingsData, value: string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Settings className='h-5 w-5' />
|
||||
System Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className='animate-pulse'>
|
||||
<div className='h-4 bg-gray-200 rounded w-1/4 mb-2'></div>
|
||||
<div className='h-10 bg-gray-200 rounded w-1/2'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Settings className='h-5 w-5' />
|
||||
System Settings
|
||||
</CardTitle>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' variant='outline' onClick={fetchSettings}>
|
||||
<RefreshCw className='h-4 w-4 mr-2' />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size='sm' onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className='h-4 w-4 mr-2' />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
{/* Club/Brand Configuration Section */}
|
||||
<div>
|
||||
<h3 className='text-lg font-medium mb-4'>Club & Branding</h3>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
|
||||
{/* Club Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='club_name'>Club Name</Label>
|
||||
<Input
|
||||
id='club_name'
|
||||
type='text'
|
||||
placeholder='e.g., Downtown TT Club'
|
||||
value={settings.club_name}
|
||||
onChange={(e) => updateSetting('club_name', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>The name of your club or organization</p>
|
||||
</div>
|
||||
|
||||
{/* Sport Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='sport_name'>Sport Name</Label>
|
||||
<Input
|
||||
id='sport_name'
|
||||
type='text'
|
||||
placeholder='e.g., Table Tennis, Ping Pong, Badminton'
|
||||
value={settings.sport_name}
|
||||
onChange={(e) => updateSetting('sport_name', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>The sport played at your facility</p>
|
||||
</div>
|
||||
|
||||
{/* App Title */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='app_title'>Application Title</Label>
|
||||
<Input
|
||||
id='app_title'
|
||||
type='text'
|
||||
placeholder='e.g., Downtown TT Booking'
|
||||
value={settings.app_title}
|
||||
onChange={(e) => updateSetting('app_title', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>Main title shown in browser and app header</p>
|
||||
</div>
|
||||
|
||||
{/* App Description */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='app_description'>Application Description</Label>
|
||||
<Input
|
||||
id='app_description'
|
||||
type='text'
|
||||
placeholder='e.g., Book your court slots with ease'
|
||||
value={settings.app_description}
|
||||
onChange={(e) => updateSetting('app_description', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>Short description for login/register pages</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Configuration Section */}
|
||||
<div>
|
||||
<h3 className='text-lg font-medium mb-4'>Booking Configuration</h3>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
|
||||
{/* Booking Window */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='booking_window_days'>Booking Window (days)</Label>
|
||||
<Input
|
||||
id='booking_window_days'
|
||||
type='number'
|
||||
min='1'
|
||||
max='30'
|
||||
value={settings.booking_window_days}
|
||||
onChange={(e) => updateSetting('booking_window_days', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>How many days in advance users can book</p>
|
||||
</div>
|
||||
|
||||
{/* Max Duration */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='max_booking_duration_hours'>Max Booking Duration (hours)</Label>
|
||||
<Input
|
||||
id='max_booking_duration_hours'
|
||||
type='number'
|
||||
min='0.5'
|
||||
max='8'
|
||||
step='0.5'
|
||||
value={settings.max_booking_duration_hours}
|
||||
onChange={(e) => updateSetting('max_booking_duration_hours', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>Maximum hours per booking session</p>
|
||||
</div>
|
||||
|
||||
{/* Min Duration */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='min_booking_duration_minutes'>Min Booking Duration (minutes)</Label>
|
||||
<Input
|
||||
id='min_booking_duration_minutes'
|
||||
type='number'
|
||||
min='15'
|
||||
max='120'
|
||||
step='15'
|
||||
value={settings.min_booking_duration_minutes}
|
||||
onChange={(e) => updateSetting('min_booking_duration_minutes', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>Minimum minutes per booking session</p>
|
||||
</div>
|
||||
|
||||
{/* Start Time */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='booking_start_time'>Daily Start Time</Label>
|
||||
<Input
|
||||
id='booking_start_time'
|
||||
type='time'
|
||||
value={settings.booking_start_time}
|
||||
onChange={(e) => updateSetting('booking_start_time', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>When courts open for booking each day</p>
|
||||
</div>
|
||||
|
||||
{/* End Time */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='booking_end_time'>Daily End Time</Label>
|
||||
<Input
|
||||
id='booking_end_time'
|
||||
type='time'
|
||||
value={settings.booking_end_time}
|
||||
onChange={(e) => updateSetting('booking_end_time', e.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>When courts close for booking each day</p>
|
||||
</div>
|
||||
|
||||
{/* Booking Restrictions */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='max_bookings_per_user_per_hour_per_day'>
|
||||
Max Bookings per User per Hour
|
||||
</Label>
|
||||
<Input
|
||||
id='max_bookings_per_user_per_hour_per_day'
|
||||
type='number'
|
||||
min='1'
|
||||
max='5'
|
||||
value={settings.max_bookings_per_user_per_hour_per_day}
|
||||
onChange={(e) =>
|
||||
updateSetting('max_bookings_per_user_per_hour_per_day', e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>Maximum bookings per user per hour on the same day</p>
|
||||
</div>
|
||||
|
||||
{/* Booking Modification Settings */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Switch
|
||||
id='allow_booking_modifications'
|
||||
checked={settings.allow_booking_modifications === 'true'}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
updateSetting('allow_booking_modifications', checked.toString())
|
||||
}
|
||||
/>
|
||||
<Label htmlFor='allow_booking_modifications'>Allow Booking Modifications</Label>
|
||||
</div>
|
||||
<p className='text-sm text-gray-500'>Whether users can edit or cancel their bookings</p>
|
||||
</div>
|
||||
|
||||
{/* Modification Time Restriction */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='booking_modification_hours_before'>
|
||||
Modification Time Limit (hours before session)
|
||||
</Label>
|
||||
<Input
|
||||
id='booking_modification_hours_before'
|
||||
type='number'
|
||||
min='0.5'
|
||||
max='48'
|
||||
step='0.5'
|
||||
value={settings.booking_modification_hours_before}
|
||||
onChange={(e) => updateSetting('booking_modification_hours_before', e.target.value)}
|
||||
disabled={settings.allow_booking_modifications !== 'true'}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>
|
||||
{settings.allow_booking_modifications === 'true'
|
||||
? 'How many hours before a session users can still modify bookings'
|
||||
: 'Enable booking modifications to configure this setting'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Weekend Bookings */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Switch
|
||||
id='allow_weekend_bookings'
|
||||
checked={settings.allow_weekend_bookings === 'true'}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
updateSetting('allow_weekend_bookings', checked.toString())
|
||||
}
|
||||
/>
|
||||
<Label htmlFor='allow_weekend_bookings'>Allow Weekend Bookings</Label>
|
||||
</div>
|
||||
<p className='text-sm text-gray-500'>Whether users can book courts on weekends</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-t pt-6'>
|
||||
<h3 className='text-lg font-medium mb-4'>Current Configuration Summary</h3>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-4 text-sm'>
|
||||
<div className='space-y-2'>
|
||||
<p>
|
||||
<strong>Booking Window:</strong> {settings.booking_window_days} days
|
||||
</p>
|
||||
<p>
|
||||
<strong>Session Duration:</strong> {settings.min_booking_duration_minutes}min -{' '}
|
||||
{settings.max_booking_duration_hours}hrs
|
||||
</p>
|
||||
<p>
|
||||
<strong>Operating Hours:</strong> {settings.booking_start_time} -{' '}
|
||||
{settings.booking_end_time}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<p>
|
||||
<strong>Weekend Bookings:</strong>{' '}
|
||||
{settings.allow_weekend_bookings === 'true' ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Booking Limit:</strong> {settings.max_bookings_per_user_per_hour_per_day} per
|
||||
hour
|
||||
</p>
|
||||
<p>
|
||||
<strong>Booking Modifications:</strong>{' '}
|
||||
{settings.allow_booking_modifications === 'true' ? 'Enabled' : 'Disabled'}
|
||||
{settings.allow_booking_modifications === 'true' &&
|
||||
` (${settings.booking_modification_hours_before}h before)`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Plus, Edit, Trash2, Clock } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getWeekDays } from '@/lib/utils';
|
||||
|
||||
interface TimeSlot {
|
||||
id: string;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Use Irish week order (Monday first)
|
||||
const DAYS = getWeekDays().map((day) => day.label);
|
||||
const IRISH_DAY_ORDER = [1, 2, 3, 4, 5, 6, 0]; // Monday-Sunday in JS getDay() values
|
||||
|
||||
export function AdminTimeSlotManagement() {
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showWipeDayDialog, setShowWipeDayDialog] = useState(false);
|
||||
const [editingSlot, setEditingSlot] = useState<TimeSlot | null>(null);
|
||||
const [slotToDelete, setSlotToDelete] = useState<TimeSlot | null>(null);
|
||||
const [dayToWipe, setDayToWipe] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
dayOfWeek: 1, // Default to Monday (Irish standard)
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
isActive: true,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTimeSlots();
|
||||
}, []);
|
||||
|
||||
const fetchTimeSlots = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/time-slots');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTimeSlots(data.timeSlots);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch time slots',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching time slots:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch time slots',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const url = editingSlot ? `/api/admin/time-slots/${editingSlot.id}` : '/api/admin/time-slots';
|
||||
|
||||
const method = editingSlot ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: editingSlot ? 'Time slot updated successfully' : 'Time slot created successfully',
|
||||
});
|
||||
fetchTimeSlots();
|
||||
setShowDialog(false);
|
||||
resetForm();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.error || 'Failed to save time slot',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving time slot:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to save time slot',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (slot: TimeSlot) => {
|
||||
setSlotToDelete(slot);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const confirmDeleteSlot = async () => {
|
||||
if (slotToDelete) {
|
||||
await handleDelete(slotToDelete.id);
|
||||
setShowDeleteDialog(false);
|
||||
setSlotToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/admin/time-slots/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Time slot deleted successfully',
|
||||
});
|
||||
fetchTimeSlots();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.error || 'Failed to delete time slot',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting time slot:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete time slot',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openWipeDayDialog = (dayOfWeek: number) => {
|
||||
setDayToWipe(dayOfWeek);
|
||||
setShowWipeDayDialog(true);
|
||||
};
|
||||
|
||||
const confirmWipeDay = async () => {
|
||||
if (dayToWipe !== null) {
|
||||
await handleWipeDay(dayToWipe);
|
||||
setShowWipeDayDialog(false);
|
||||
setDayToWipe(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWipeDay = async (dayOfWeek: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const dayName = DAYS[dayOfWeek];
|
||||
const slotsToDelete = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek);
|
||||
|
||||
// Delete all slots for this day
|
||||
const deletePromises = slotsToDelete.map((slot) =>
|
||||
fetch(`/api/admin/time-slots/${slot.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await Promise.all(deletePromises);
|
||||
const successCount = responses.filter((response) => response.ok).length;
|
||||
|
||||
if (successCount === slotsToDelete.length) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `All ${dayName} time slots deleted successfully`,
|
||||
});
|
||||
fetchTimeSlots();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Partial Success',
|
||||
description: `${successCount} of ${slotsToDelete.length} slots deleted`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error wiping day slots:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete day slots',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (slot: TimeSlot) => {
|
||||
setEditingSlot(slot);
|
||||
setFormData({
|
||||
dayOfWeek: slot.dayOfWeek,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
isActive: slot.isActive,
|
||||
});
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingSlot(null);
|
||||
setFormData({
|
||||
dayOfWeek: 1, // Default to Monday (Irish standard)
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
isActive: true,
|
||||
});
|
||||
};
|
||||
|
||||
const groupedTimeSlots = timeSlots.reduce((acc, slot) => {
|
||||
if (!acc[slot.dayOfWeek]) {
|
||||
acc[slot.dayOfWeek] = [];
|
||||
}
|
||||
acc[slot.dayOfWeek].push(slot);
|
||||
return acc;
|
||||
}, {} as Record<number, TimeSlot[]>);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex justify-between items-center'>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Clock className='h-5 w-5' />
|
||||
Time Slot Management
|
||||
</CardTitle>
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={resetForm}>
|
||||
<Plus className='h-4 w-4 mr-2' />
|
||||
Add Time Slot
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingSlot ? 'Edit Time Slot' : 'Add New Time Slot'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='dayOfWeek'>Day of Week</Label>
|
||||
<Select
|
||||
value={formData.dayOfWeek.toString()}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, dayOfWeek: parseInt(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select day' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{IRISH_DAY_ORDER.map((jsDayOfWeek, displayIndex) => (
|
||||
<SelectItem key={jsDayOfWeek} value={jsDayOfWeek.toString()}>
|
||||
{DAYS[displayIndex]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='startTime'>Start Time</Label>
|
||||
<Input
|
||||
id='startTime'
|
||||
type='time'
|
||||
value={formData.startTime}
|
||||
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='endTime'>End Time</Label>
|
||||
<Input
|
||||
id='endTime'
|
||||
type='time'
|
||||
value={formData.endTime}
|
||||
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Switch
|
||||
id='isActive'
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor='isActive'>Active</Label>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={() => setShowDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading ? 'Saving...' : editingSlot ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && timeSlots.length === 0 ? (
|
||||
<div className='text-center py-4'>Loading time slots...</div>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
{IRISH_DAY_ORDER.map((jsDayOfWeek, displayIndex) => {
|
||||
const dayName = DAYS[displayIndex];
|
||||
return (
|
||||
<div key={jsDayOfWeek} className='space-y-2'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h3 className='font-semibold text-lg'>{dayName}</h3>
|
||||
{groupedTimeSlots[jsDayOfWeek]?.length > 0 && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => openWipeDayDialog(jsDayOfWeek)}
|
||||
className='text-destructive hover:text-destructive/80 hover:bg-destructive/10'
|
||||
disabled={loading}
|
||||
>
|
||||
<Trash2 className='h-4 w-4 mr-1' />
|
||||
Wipe All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{groupedTimeSlots[jsDayOfWeek]?.length > 0 ? (
|
||||
<div className='grid gap-2'>
|
||||
{groupedTimeSlots[jsDayOfWeek]
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
.map((slot) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg ${
|
||||
slot.isActive
|
||||
? 'bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800'
|
||||
: 'bg-muted border-border'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<div className='font-medium'>
|
||||
{slot.startTime} - {slot.endTime}
|
||||
</div>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
slot.isActive
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{slot.isActive ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleEdit(slot)}
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => openDeleteDialog(slot)}
|
||||
className='text-destructive hover:text-destructive/80'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground italic'>
|
||||
No time slots configured for {dayName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Delete Time Slot Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this time slot? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteSlot}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Wipe Day Dialog */}
|
||||
<AlertDialog open={showWipeDayDialog} onOpenChange={setShowWipeDayDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete ALL time slots for{' '}
|
||||
{dayToWipe !== null ? DAYS[dayToWipe] : 'this day'}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmWipeDay} className='bg-destructive hover:bg-destructive/90'>
|
||||
Delete All
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { UserPlus, Edit, Trash2, Search, Users, Mail, Calendar } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
email: string;
|
||||
role: 'user' | 'admin';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserFormData {
|
||||
name: string;
|
||||
surname: string;
|
||||
email: string;
|
||||
role: 'user' | 'admin';
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export function AdminUserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||
const [formData, setFormData] = useState<UserFormData>({
|
||||
name: '',
|
||||
surname: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
password: '',
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/users');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users);
|
||||
} else {
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch users',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e?: React.FormEvent) => {
|
||||
try {
|
||||
// Prevent form submission and double submissions
|
||||
if (e) e.preventDefault();
|
||||
if (loading) return;
|
||||
|
||||
if (!formData.name || !formData.surname || !formData.email || !formData.password) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please fill in all required fields',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User created successfully',
|
||||
});
|
||||
setIsCreateDialogOpen(false);
|
||||
resetForm();
|
||||
fetchUsers();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to create user',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to create user',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUser = async () => {
|
||||
try {
|
||||
// Prevent double submissions
|
||||
if (loading) return;
|
||||
|
||||
if (!editingUser || !formData.name || !formData.surname || !formData.email) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please fill in all required fields',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const updateData = { ...formData };
|
||||
if (!updateData.password) {
|
||||
delete updateData.password; // Don't update password if not provided
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/users/${editingUser.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User updated successfully',
|
||||
});
|
||||
setIsEditDialogOpen(false);
|
||||
setEditingUser(null);
|
||||
resetForm();
|
||||
fetchUsers();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to update user',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update user',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (user: User) => {
|
||||
setUserToDelete(user);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteUser = async () => {
|
||||
if (userToDelete) {
|
||||
await handleDeleteUser(userToDelete.id);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setUserToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'User deleted successfully',
|
||||
});
|
||||
fetchUsers();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to delete user',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete user',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
name: user.name,
|
||||
surname: user.surname,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
password: '', // Don't pre-fill password
|
||||
});
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
surname: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
password: '',
|
||||
});
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.surname.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className='flex justify-center items-center py-8'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Users className='h-6 w-6' />
|
||||
<h2 className='text-2xl font-bold'>User Management</h2>
|
||||
</div>
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => resetForm()}>
|
||||
<UserPlus className='h-4 w-4 mr-2' />
|
||||
Add User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateUser} className='space-y-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Label htmlFor='name'>First Name</Label>
|
||||
<Input
|
||||
id='name'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder='John'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='surname'>Last Name</Label>
|
||||
<Input
|
||||
id='surname'
|
||||
value={formData.surname}
|
||||
onChange={(e) => setFormData({ ...formData, surname: e.target.value })}
|
||||
placeholder='Doe'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
<Input
|
||||
id='email'
|
||||
type='email'
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder='john.doe@example.com'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
<Input
|
||||
id='password'
|
||||
type='password'
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder='Enter password'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='role'>Role</Label>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value: 'user' | 'admin') =>
|
||||
setFormData({ ...formData, role: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select role' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='user'>User</SelectItem>
|
||||
<SelectItem value='admin'>Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex gap-2 justify-end'>
|
||||
<Button type='button' variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Search className='h-4 w-4 text-gray-500' />
|
||||
<Input
|
||||
placeholder='Search users by name or email...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className='max-w-sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Users ({filteredUsers.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className='font-medium'>
|
||||
{user.name} {user.surname}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Mail className='h-4 w-4 text-gray-500' />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4 text-gray-500' />
|
||||
{new Date(user.createdAt).toLocaleDateString('en-IE')}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='outline' size='sm' onClick={() => openEditDialog(user)}>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => openDeleteDialog(user)}
|
||||
className='text-red-600 hover:text-red-700'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className='text-center py-8 text-gray-500'>
|
||||
No users found matching your search criteria
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Label htmlFor='edit-name'>First Name</Label>
|
||||
<Input
|
||||
id='edit-name'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder='John'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='edit-surname'>Last Name</Label>
|
||||
<Input
|
||||
id='edit-surname'
|
||||
value={formData.surname}
|
||||
onChange={(e) => setFormData({ ...formData, surname: e.target.value })}
|
||||
placeholder='Doe'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='edit-email'>Email</Label>
|
||||
<Input
|
||||
id='edit-email'
|
||||
type='email'
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder='john.doe@example.com'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='edit-password'>New Password (leave blank to keep current)</Label>
|
||||
<Input
|
||||
id='edit-password'
|
||||
type='password'
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder='Enter new password'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='edit-role'>Role</Label>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value: 'user' | 'admin') => setFormData({ ...formData, role: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select role' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='user'>User</SelectItem>
|
||||
<SelectItem value='admin'>Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex gap-2 justify-end'>
|
||||
<Button variant='outline' onClick={() => setIsEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleEditUser} disabled={loading}>
|
||||
{loading ? 'Updating...' : 'Update User'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{' '}
|
||||
{userToDelete ? `${userToDelete.name} ${userToDelete.surname}` : 'this user'}? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteUser}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Users, Calendar, Settings, BarChart3, Bell, Shield, Clock, MapPin, Activity, LogOut } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AdminUserManagement } from './AdminUserManagement';
|
||||
import { AdminAnnouncementManagement } from './AdminAnnouncementManagement';
|
||||
import { AdminLogs } from './AdminLogs';
|
||||
import { AdminRecentBookings } from './AdminRecentBookings';
|
||||
import { AdminCourtManagement } from './AdminCourtManagement';
|
||||
import { AdminSettingsManagement } from './AdminSettingsManagement';
|
||||
import { AdminTimeSlotManagement } from './AdminTimeSlotManagement';
|
||||
import { ModeToggle } from '@/components/ui/mode-toggle';
|
||||
|
||||
interface AdminStats {
|
||||
totalUsers: number;
|
||||
activeCourts: number;
|
||||
todaysBookings: number;
|
||||
monthlyBookings: number;
|
||||
}
|
||||
|
||||
interface RecentBooking {
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
courtName: string;
|
||||
userName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function AdminDashboard() {
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState<AdminStats>({
|
||||
totalUsers: 0,
|
||||
activeCourts: 0,
|
||||
todaysBookings: 0,
|
||||
monthlyBookings: 0,
|
||||
});
|
||||
const [recentBookings, setRecentBookings] = useState<RecentBooking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/stats');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data.stats);
|
||||
setRecentBookings(data.recentBookings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-background'>
|
||||
{/* Header */}
|
||||
<header className='bg-card border-b border-border'>
|
||||
<div className='container mx-auto px-4'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center space-x-4'>
|
||||
<Shield className='h-6 w-6 text-blue-600 dark:text-blue-400' />
|
||||
<h1 className='text-xl font-semibold text-foreground'>Admin Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-4'>
|
||||
<Badge variant='secondary'>Administrator</Badge>
|
||||
<ModeToggle />
|
||||
<Button variant='ghost' size='sm' onClick={handleLogout}>
|
||||
<LogOut className='h-4 w-4' />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className='container mx-auto px-4 py-8'>
|
||||
{/* Stats Cards */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8'>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Total Users</CardTitle>
|
||||
<Users className='h-4 w-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{loading ? '...' : stats.totalUsers}</div>
|
||||
<p className='text-xs text-muted-foreground'>Registered users</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Active Courts</CardTitle>
|
||||
<MapPin className='h-4 w-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{loading ? '...' : stats.activeCourts}</div>
|
||||
<p className='text-xs text-muted-foreground'>Available for booking</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Today's Bookings</CardTitle>
|
||||
<Calendar className='h-4 w-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{loading ? '...' : stats.todaysBookings}</div>
|
||||
<p className='text-xs text-muted-foreground'>Bookings for today</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Monthly Bookings</CardTitle>
|
||||
<BarChart3 className='h-4 w-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{loading ? '...' : stats.monthlyBookings}</div>
|
||||
<p className='text-xs text-muted-foreground'>This month's total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Admin Tabs */}
|
||||
<Tabs defaultValue='bookings' className='space-y-6'>
|
||||
<TabsList className='grid w-full grid-cols-6'>
|
||||
<TabsTrigger value='bookings'>Bookings</TabsTrigger>
|
||||
<TabsTrigger value='users'>Users</TabsTrigger>
|
||||
<TabsTrigger value='courts'>Courts</TabsTrigger>
|
||||
<TabsTrigger value='settings'>Settings</TabsTrigger>
|
||||
<TabsTrigger value='announcements'>Announcements</TabsTrigger>
|
||||
<TabsTrigger value='logs'>Logs</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='bookings'>
|
||||
<AdminRecentBookings />
|
||||
</TabsContent>
|
||||
<TabsContent value='users'>
|
||||
<AdminUserManagement />
|
||||
</TabsContent>
|
||||
<TabsContent value='courts'>
|
||||
<AdminCourtManagement />
|
||||
</TabsContent>{' '}
|
||||
<TabsContent value='settings'>
|
||||
<div className='space-y-6'>
|
||||
<AdminSettingsManagement />
|
||||
<AdminTimeSlotManagement />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value='announcements'>
|
||||
<AdminAnnouncementManagement />
|
||||
</TabsContent>{' '}
|
||||
<TabsContent value='logs'>
|
||||
<AdminLogs />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Bell, Info, AlertTriangle, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
isActive: boolean;
|
||||
expiresAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function AnnouncementsList() {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnnouncements();
|
||||
}, []);
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/announcements');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Filter to show only active, non-expired announcements
|
||||
const activeAnnouncements = data.announcements.filter((announcement: Announcement) => {
|
||||
if (!announcement.isActive) return false;
|
||||
if (announcement.expiresAt && new Date(announcement.expiresAt) < new Date()) return false;
|
||||
return true;
|
||||
});
|
||||
setAnnouncements(activeAnnouncements);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching announcements:', error);
|
||||
// Fallback to default announcements if API fails
|
||||
setAnnouncements([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Welcome to Table Tennis Booking!',
|
||||
content:
|
||||
'Book your favorite court slots up to 7 days in advance. Remember to arrive 5 minutes early for your booking.',
|
||||
priority: 'medium',
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return <AlertCircle className='h-4 w-4 text-destructive' />;
|
||||
case 'medium':
|
||||
return <AlertTriangle className='h-4 w-4 text-amber-500 dark:text-amber-400' />;
|
||||
default:
|
||||
return <Info className='h-4 w-4 text-primary' />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'bg-destructive/10 text-destructive border-destructive/20 dark:bg-destructive/20';
|
||||
case 'medium':
|
||||
return 'bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-800/30';
|
||||
default:
|
||||
return 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Bell className='h-5 w-5' />
|
||||
Announcements
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{announcements
|
||||
.filter((a) => a.isActive)
|
||||
.map((announcement) => (
|
||||
<div key={announcement.id} className='p-4 border rounded-lg bg-card'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex items-start gap-2 flex-1'>
|
||||
{getPriorityIcon(announcement.priority)}
|
||||
<div className='space-y-1'>
|
||||
<h4 className='font-medium text-sm text-foreground'>
|
||||
{announcement.title}
|
||||
</h4>
|
||||
<p className='text-sm text-muted-foreground'>{announcement.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={`text-xs ${getPriorityColor(announcement.priority)}`}
|
||||
>
|
||||
{announcement.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{announcements.filter((a) => a.isActive).length === 0 && (
|
||||
<div className='text-center py-8 text-muted-foreground'>
|
||||
<Bell className='h-8 w-8 mx-auto mb-2 text-muted-foreground/30' />
|
||||
<p>No announcements at this time</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Login failed');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Welcome back!',
|
||||
description: "You've been successfully logged in.",
|
||||
});
|
||||
|
||||
// Redirect based on user role
|
||||
if (result.user.role === 'admin') {
|
||||
router.push('/admin');
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
|
||||
// Refresh the page to update auth state
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Login failed',
|
||||
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='w-full max-w-md mx-auto'>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='text-2xl text-center'>Welcome back</CardTitle>
|
||||
<CardDescription className='text-center'>Enter your credentials to access your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter your email'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter your password'
|
||||
type='password'
|
||||
autoComplete='current-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
surname: z.string().min(2, 'Surname must be at least 2 characters'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type RegisterForm = z.infer<typeof registerSchema>;
|
||||
|
||||
export function RegisterForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<RegisterForm>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: '',
|
||||
surname: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterForm) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
surname: data.surname,
|
||||
password: data.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Registration failed');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Account created!',
|
||||
description: 'Welcome to the table tennis booking system.',
|
||||
});
|
||||
|
||||
// Redirect to dashboard after successful registration
|
||||
router.push('/dashboard');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Registration failed',
|
||||
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='w-full max-w-md mx-auto'>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='text-2xl text-center'>Create an account</CardTitle>
|
||||
<CardDescription className='text-center'>Enter your details to create your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='John' autoComplete='given-name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='surname'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Doe' autoComplete='family-name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='john@example.com'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Create a password'
|
||||
type='password'
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Confirm your password'
|
||||
type='password'
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,722 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight, Users, User } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface Court {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Booking {
|
||||
id: string;
|
||||
courtId: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: string;
|
||||
userId: string;
|
||||
notes?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface BookingSlot {
|
||||
time: string;
|
||||
courtId: string;
|
||||
courtName: string;
|
||||
available: boolean;
|
||||
bookingId?: string;
|
||||
bookedBy?: string;
|
||||
partner?: string;
|
||||
}
|
||||
|
||||
interface TimeSlot {
|
||||
id: string;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
booking_window_days: string;
|
||||
booking_start_time: string;
|
||||
booking_end_time: string;
|
||||
allow_weekend_bookings: string;
|
||||
}
|
||||
|
||||
export function EnhancedBookingCalendar() {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const [courts, setCourts] = useState<Court[]>([]);
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [partnerName, setPartnerName] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showBookingDialog, setShowBookingDialog] = useState(false);
|
||||
const [selectedSlot, setSelectedSlot] = useState<BookingSlot | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchCourts();
|
||||
fetchTimeSlots();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (courts.length > 0 && timeSlots.length > 0) {
|
||||
fetchBookings();
|
||||
}
|
||||
}, [selectedDate, courts, timeSlots]);
|
||||
|
||||
// Fetch settings from public endpoint (not admin)
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const settingsMap: Settings = {
|
||||
booking_window_days: '7',
|
||||
booking_start_time: '08:00',
|
||||
booking_end_time: '22:00',
|
||||
allow_weekend_bookings: 'true',
|
||||
};
|
||||
|
||||
data.settings.forEach((setting: any) => {
|
||||
if (setting.key in settingsMap) {
|
||||
settingsMap[setting.key as keyof Settings] = setting.value;
|
||||
}
|
||||
});
|
||||
|
||||
setSettings(settingsMap);
|
||||
} else {
|
||||
// If settings fetch fails, use defaults
|
||||
setSettings({
|
||||
booking_window_days: '7',
|
||||
booking_start_time: '08:00',
|
||||
booking_end_time: '22:00',
|
||||
allow_weekend_bookings: 'true',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
// Set default settings
|
||||
setSettings({
|
||||
booking_window_days: '7',
|
||||
booking_start_time: '08:00',
|
||||
booking_end_time: '22:00',
|
||||
allow_weekend_bookings: 'true',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch courts from public endpoint (not admin)
|
||||
const fetchCourts = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/courts');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCourts(data.courts.filter((court: Court) => court.isActive));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching courts:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch courts',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch time slots for day-specific booking times
|
||||
const fetchTimeSlots = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/time-slots');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTimeSlots(data.timeSlots);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching time slots:', error);
|
||||
// If time slots fetch fails, we'll use fallback settings
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||
const response = await fetch(`/api/bookings/all?date=${dateStr}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBookings(data.bookings);
|
||||
generateBookingSlots(data.bookings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching bookings:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch bookings',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const generateTimeSlots = (): string[] => {
|
||||
const dayOfWeek = selectedDate.getDay();
|
||||
|
||||
// Get time slots for the selected day
|
||||
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
|
||||
|
||||
if (dayTimeSlots.length > 0) {
|
||||
// Use day-specific time slots
|
||||
const slots: string[] = [];
|
||||
dayTimeSlots.forEach((timeSlot) => {
|
||||
const start = parseInt(timeSlot.startTime.split(':')[0]);
|
||||
const end = parseInt(timeSlot.endTime.split(':')[0]);
|
||||
|
||||
for (let hour = start; hour < end; hour++) {
|
||||
slots.push(`${hour.toString().padStart(2, '0')}:00`);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove duplicates and sort
|
||||
return [...new Set(slots)].sort();
|
||||
}
|
||||
|
||||
// NO FALLBACK - If no day-specific time slots, return empty array
|
||||
// This prevents booking on days where no play is scheduled
|
||||
return [];
|
||||
};
|
||||
|
||||
const isDayBookable = (): boolean => {
|
||||
const dayOfWeek = selectedDate.getDay();
|
||||
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
|
||||
return dayTimeSlots.length > 0;
|
||||
};
|
||||
|
||||
const getDayName = (dayOfWeek: number): string => {
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
return days[dayOfWeek];
|
||||
};
|
||||
|
||||
const parseBookingNotes = (notes?: string) => {
|
||||
if (!notes) return { partner: '', additionalNotes: '' };
|
||||
|
||||
const parts = notes.split(' | ');
|
||||
let partner = '';
|
||||
let additionalNotes = '';
|
||||
|
||||
parts.forEach((part) => {
|
||||
if (part.startsWith('Partner: ')) {
|
||||
partner = part.replace('Partner: ', '');
|
||||
} else {
|
||||
additionalNotes = additionalNotes ? `${additionalNotes} | ${part}` : part;
|
||||
}
|
||||
});
|
||||
|
||||
return { partner, additionalNotes };
|
||||
};
|
||||
|
||||
const generateBookingSlots = (existingBookings: Booking[]) => {
|
||||
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||
const timeSlots = generateTimeSlots();
|
||||
const slots: BookingSlot[] = [];
|
||||
|
||||
courts.forEach((court) => {
|
||||
timeSlots.forEach((time) => {
|
||||
const existingBooking = existingBookings.find(
|
||||
(booking) =>
|
||||
booking.courtId === court.id &&
|
||||
booking.date === dateStr &&
|
||||
booking.startTime === time &&
|
||||
booking.status === 'active'
|
||||
);
|
||||
|
||||
const bookedBy = existingBooking?.user
|
||||
? `${existingBooking.user.name} ${existingBooking.user.surname}`
|
||||
: undefined;
|
||||
|
||||
const { partner } = parseBookingNotes(existingBooking?.notes);
|
||||
|
||||
slots.push({
|
||||
time,
|
||||
courtId: court.id,
|
||||
courtName: court.name,
|
||||
available: !existingBooking,
|
||||
bookingId: existingBooking?.id,
|
||||
bookedBy,
|
||||
partner,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setBookingSlots(slots);
|
||||
};
|
||||
|
||||
const isDateSelectable = (date: Date): boolean => {
|
||||
if (!settings) return false;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const selectedDateOnly = new Date(date);
|
||||
selectedDateOnly.setHours(0, 0, 0, 0);
|
||||
|
||||
// Check if date is in the past
|
||||
if (selectedDateOnly < today) return false;
|
||||
|
||||
// Check booking window
|
||||
const maxDate = new Date(today);
|
||||
maxDate.setDate(today.getDate() + parseInt(settings.booking_window_days));
|
||||
if (selectedDateOnly > maxDate) return false;
|
||||
|
||||
// CRITICAL: Check if there are any active time slots for this day
|
||||
const dayOfWeek = selectedDateOnly.getDay();
|
||||
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
|
||||
|
||||
// If no time slots are configured for this day, it's not selectable
|
||||
if (dayTimeSlots.length === 0) return false;
|
||||
|
||||
// Legacy weekend restriction check (now superseded by time slot configuration)
|
||||
// Keep for backward compatibility if global settings still matter
|
||||
if (settings.allow_weekend_bookings === 'false') {
|
||||
const dayOfWeek = selectedDateOnly.getDay();
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) return false; // Sunday or Saturday
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSlotClick = (slot: BookingSlot) => {
|
||||
if (!slot.available) return;
|
||||
|
||||
// Double-check that this day is actually bookable
|
||||
if (!isDayBookable()) {
|
||||
toast({
|
||||
title: 'Booking Not Available',
|
||||
description: `Courts are closed on ${getDayName(selectedDate.getDay())}s`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSlot(slot);
|
||||
setPartnerName('');
|
||||
setNotes('');
|
||||
setShowBookingDialog(true);
|
||||
};
|
||||
|
||||
const handleBookingConfirm = async () => {
|
||||
if (!selectedSlot) return;
|
||||
|
||||
// Final validation before API call
|
||||
if (!isDayBookable()) {
|
||||
toast({
|
||||
title: 'Booking Not Available',
|
||||
description: `Courts are closed on ${getDayName(selectedDate.getDay())}s`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setShowBookingDialog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||
const bookingNotes = [];
|
||||
|
||||
if (partnerName.trim()) {
|
||||
bookingNotes.push(`Partner: ${partnerName.trim()}`);
|
||||
}
|
||||
if (notes.trim()) {
|
||||
bookingNotes.push(notes.trim());
|
||||
}
|
||||
|
||||
const response = await fetch('/api/bookings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
courtId: selectedSlot.courtId,
|
||||
date: dateStr,
|
||||
timeSlot: selectedSlot.time,
|
||||
notes: bookingNotes.join(' | '),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Booking created successfully!',
|
||||
});
|
||||
setShowBookingDialog(false);
|
||||
fetchBookings(); // Refresh bookings
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to create booking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error booking slot:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to create booking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateDate = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(selectedDate);
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||
|
||||
if (isDateSelectable(newDate)) {
|
||||
setSelectedDate(newDate);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableDates = (): Date[] => {
|
||||
if (!settings) return [];
|
||||
|
||||
const dates: Date[] = [];
|
||||
const today = new Date();
|
||||
const maxDays = parseInt(settings.booking_window_days);
|
||||
|
||||
for (let i = 0; i <= maxDays; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
|
||||
if (isDateSelectable(date)) {
|
||||
dates.push(date);
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
};
|
||||
|
||||
const isPastDate = (date: Date) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return date < today;
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className='p-6'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||
<p className='ml-2'>Loading booking system...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Calendar className='h-5 w-5' />
|
||||
Book Your Court
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Mobile-friendly date navigation */}
|
||||
<div className='space-y-6'>
|
||||
{/* Quick Date Selection */}
|
||||
<div className='space-y-4'>
|
||||
<h3 className='font-medium'>Select Date</h3>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||
{getAvailableDates()
|
||||
.slice(0, 8)
|
||||
.map((date, index) => {
|
||||
const isSelectedDate = date.toDateString() === selectedDate.toDateString();
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={isSelectedDate ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={() => setSelectedDate(date)}
|
||||
className={`h-16 flex flex-col relative transition-all ${
|
||||
isSelectedDate && !isTodayDate
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: ''
|
||||
} ${
|
||||
isTodayDate && !isSelectedDate
|
||||
? 'ring-2 ring-primary/20 bg-accent border-primary/20 hover:bg-accent/80 text-foreground'
|
||||
: ''
|
||||
} ${
|
||||
isSelectedDate && isTodayDate
|
||||
? 'bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-primary-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isTodayDate && (
|
||||
<div className='absolute -top-1 -right-1 w-3 h-3 bg-orange-500 dark:bg-orange-400 rounded-full animate-pulse' />
|
||||
)}
|
||||
<span className='text-xs font-normal'>
|
||||
{date.toLocaleDateString('en-IE', { weekday: 'short' })}
|
||||
</span>
|
||||
<span className='font-semibold'>{date.getDate()}</span>
|
||||
<span className='text-xs font-normal'>
|
||||
{date.toLocaleDateString('en-IE', { month: 'short' })}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Date Display */}
|
||||
<div
|
||||
className={`text-center p-4 rounded-lg ${
|
||||
isToday(selectedDate)
|
||||
? 'bg-gradient-to-r from-primary to-primary/80 text-primary-foreground'
|
||||
: 'bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
className={`text-lg font-semibold ${
|
||||
isToday(selectedDate) ? 'text-primary-foreground' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{selectedDate.toLocaleDateString('en-IE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</h3>
|
||||
{isToday(selectedDate) && (
|
||||
<div className='flex items-center justify-center gap-2 mt-2'>
|
||||
<div className='w-2 h-2 bg-orange-300 dark:bg-orange-400 rounded-full animate-pulse' />
|
||||
<span className='text-sm font-medium text-primary-foreground/90'>Today</span>
|
||||
<div className='w-2 h-2 bg-orange-300 dark:bg-orange-400 rounded-full animate-pulse' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className='text-center py-8'>
|
||||
<div className='inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary'></div>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>Loading booking slots...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Courts Available */}
|
||||
{!loading && courts.length === 0 && (
|
||||
<div className='text-center py-8'>
|
||||
<p className='text-muted-foreground'>No courts available for booking</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Slots Grid - Organized by Time */}
|
||||
{!loading && courts.length > 0 && (
|
||||
<div className='space-y-4'>
|
||||
<h3 className='font-medium'>Available Time Slots</h3>
|
||||
<div className='space-y-3'>
|
||||
{/* Group slots by time */}
|
||||
{Array.from(new Set(bookingSlots.map((slot) => slot.time)))
|
||||
.sort()
|
||||
.map((time) => {
|
||||
const slotsForTime = bookingSlots.filter((slot) => slot.time === time);
|
||||
|
||||
return (
|
||||
<div key={time} className='space-y-2'>
|
||||
<div className='flex items-center gap-2 text-sm font-medium text-foreground'>
|
||||
<Clock className='h-4 w-4' />
|
||||
{time} -{' '}
|
||||
{String(parseInt(time.split(':')[0]) + 1).padStart(2, '0')}:00
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-3 ${
|
||||
slotsForTime.length === 1
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-1 sm:grid-cols-2'
|
||||
}`}
|
||||
>
|
||||
{slotsForTime.map((slot) => (
|
||||
<div
|
||||
key={`${slot.courtId}-${slot.time}`}
|
||||
className={`p-3 border rounded-lg transition-all duration-200 ${
|
||||
slot.available
|
||||
? 'border-green-200 bg-green-50 hover:bg-green-100 hover:shadow-sm cursor-pointer dark:border-green-700 dark:bg-green-950 dark:hover:bg-green-900'
|
||||
: 'border-muted bg-muted/50 cursor-not-allowed opacity-75 hover:opacity-100'
|
||||
}`}
|
||||
onClick={() => handleSlotClick(slot)}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1 flex-1'>
|
||||
<div className='flex items-center gap-2 text-sm font-medium text-foreground'>
|
||||
<MapPin className='h-4 w-4' />
|
||||
{slot.courtName}
|
||||
</div>
|
||||
{!slot.available && slot.bookedBy && (
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
<Users className='h-3 w-3' />
|
||||
Booked by {slot.bookedBy}
|
||||
</div>
|
||||
{slot.partner && (
|
||||
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
|
||||
<User className='h-3 w-3' />
|
||||
Playing with: {slot.partner}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!slot.available && !slot.bookedBy && (
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Already booked
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
disabled={!slot.available}
|
||||
variant={
|
||||
slot.available ? 'default' : 'secondary'
|
||||
}
|
||||
className={
|
||||
slot.available
|
||||
? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 border-0'
|
||||
: 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
>
|
||||
{slot.available ? 'Book' : 'Booked'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Slots Message */}
|
||||
{!loading && courts.length > 0 && bookingSlots.length === 0 && (
|
||||
<div className='text-center py-8'>
|
||||
{!isDayBookable() ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-destructive font-medium'>
|
||||
No courts available on {getDayName(selectedDate.getDay())}s
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
This facility is closed on {getDayName(selectedDate.getDay())}s. Please
|
||||
select a different day to make a booking.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>No booking slots available for this date</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Booking Dialog */}
|
||||
<Dialog open={showBookingDialog} onOpenChange={setShowBookingDialog}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Your Booking</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
{selectedSlot && (
|
||||
<div className='bg-primary/5 border border-primary/20 p-4 rounded-lg space-y-2 dark:bg-primary/10 dark:border-primary/30'>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
{selectedDate.toLocaleDateString('en-IE', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<Clock className='h-4 w-4' />
|
||||
{selectedSlot.time} -{' '}
|
||||
{String(parseInt(selectedSlot.time.split(':')[0]) + 1).padStart(2, '0')}:00
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<MapPin className='h-4 w-4' />
|
||||
{selectedSlot.courtName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='partner'>Playing Partner (Optional)</Label>
|
||||
<div className='relative'>
|
||||
<User className='absolute left-3 top-3 h-4 w-4 text-muted-foreground' />
|
||||
<Input
|
||||
id='partner'
|
||||
placeholder='Who will you be playing with?'
|
||||
value={partnerName}
|
||||
onChange={(e) => setPartnerName(e.target.value)}
|
||||
className='pl-10'
|
||||
/>
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
Enter the name of the person you'll be playing with
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='notes'>Additional Notes (Optional)</Label>
|
||||
<Textarea
|
||||
id='notes'
|
||||
placeholder='Any additional information...'
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className='min-h-[80px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 pt-4'>
|
||||
<Button variant='outline' className='flex-1' onClick={() => setShowBookingDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className='flex-1' onClick={handleBookingConfirm} disabled={loading}>
|
||||
{loading ? 'Booking...' : 'Confirm Booking'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Calendar, Clock, MapPin, Edit, Trash2, User, RefreshCw } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface Booking {
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
court: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function UserBookingManagement() {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
const [editPartner, setEditPartner] = useState('');
|
||||
const [settings, setSettings] = useState<{
|
||||
allow_booking_modifications: string;
|
||||
booking_modification_hours_before: string;
|
||||
} | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/bookings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// API already filters to show only active future bookings (today onwards)
|
||||
setBookings(data.bookings || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching bookings:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch your bookings',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const settingsMap = {
|
||||
allow_booking_modifications: 'true',
|
||||
booking_modification_hours_before: '2',
|
||||
};
|
||||
|
||||
data.settings?.forEach((setting: any) => {
|
||||
if (setting.key in settingsMap) {
|
||||
settingsMap[setting.key as keyof typeof settingsMap] = setting.value;
|
||||
}
|
||||
});
|
||||
|
||||
setSettings(settingsMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
// Use default settings if fetch fails
|
||||
setSettings({
|
||||
allow_booking_modifications: 'true',
|
||||
booking_modification_hours_before: '2',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const parseBookingNotes = (notes?: string) => {
|
||||
if (!notes) return { partner: '', additionalNotes: '' };
|
||||
|
||||
const parts = notes.split(' | ');
|
||||
let partner = '';
|
||||
let additionalNotes = '';
|
||||
|
||||
parts.forEach((part) => {
|
||||
if (part.startsWith('Partner: ')) {
|
||||
partner = part.replace('Partner: ', '');
|
||||
} else {
|
||||
additionalNotes = additionalNotes ? `${additionalNotes} | ${part}` : part;
|
||||
}
|
||||
});
|
||||
|
||||
return { partner, additionalNotes };
|
||||
};
|
||||
|
||||
const handleEditClick = (booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
const { partner, additionalNotes } = parseBookingNotes(booking.notes);
|
||||
setEditPartner(partner);
|
||||
setEditNotes(additionalNotes);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!selectedBooking) return;
|
||||
|
||||
try {
|
||||
const bookingNotes = [];
|
||||
if (editPartner.trim()) {
|
||||
bookingNotes.push(`Partner: ${editPartner.trim()}`);
|
||||
}
|
||||
if (editNotes.trim()) {
|
||||
bookingNotes.push(editNotes.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/bookings/${selectedBooking.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notes: bookingNotes.join(' | '),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Booking updated successfully',
|
||||
});
|
||||
setEditDialogOpen(false);
|
||||
fetchBookings();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to update booking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating booking:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update booking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!selectedBooking) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/bookings/${selectedBooking.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Booking cancelled successfully',
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
fetchBookings();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to cancel booking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling booking:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to cancel booking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return format(date, 'EEE, MMM dd');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const isToday = (dateStr: string) => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return dateStr === today;
|
||||
};
|
||||
|
||||
const canModifyBooking = (booking: Booking) => {
|
||||
if (!settings || settings.allow_booking_modifications !== 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bookingDateTime = new Date(`${booking.date}T${booking.startTime}`);
|
||||
const now = new Date();
|
||||
const timeDiff = bookingDateTime.getTime() - now.getTime();
|
||||
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||
|
||||
const requiredHours = parseFloat(settings.booking_modification_hours_before) || 2;
|
||||
|
||||
// Allow modifications if booking is more than the required hours away
|
||||
return hoursDiff > requiredHours;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
Your Bookings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-3'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
||||
<div className='h-4 bg-muted rounded w-3/4 mb-2'></div>
|
||||
<div className='h-3 bg-muted rounded w-1/2'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className='pb-3 flex flex-row items-center justify-between'>
|
||||
<CardTitle className='text-base flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
Your Upcoming Bookings
|
||||
</CardTitle>
|
||||
<Button size='sm' variant='outline' onClick={fetchBookings}>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bookings.length === 0 ? (
|
||||
<div className='text-sm text-muted-foreground text-center py-6'>
|
||||
No upcoming bookings. Make your first booking!
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{bookings.map((booking) => {
|
||||
const { partner, additionalNotes } = parseBookingNotes(booking.notes);
|
||||
const canModify = canModifyBooking(booking);
|
||||
|
||||
return (
|
||||
<div key={booking.id} className='border rounded-lg p-4 space-y-3'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='space-y-2 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MapPin className='h-4 w-4 text-primary' />
|
||||
<span className='font-medium text-sm'>{booking.court.name}</span>
|
||||
{isToday(booking.date) && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='text-xs bg-orange-100 text-orange-700 border-orange-300 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-800'
|
||||
>
|
||||
🎯 Today
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-4 text-xs text-muted-foreground'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar className='h-3 w-3' />
|
||||
<span>{formatDate(booking.date)}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
<span>
|
||||
{booking.startTime} - {booking.endTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{partner && (
|
||||
<div className='flex items-center gap-1 text-xs text-muted-foreground'>
|
||||
<User className='h-3 w-3' />
|
||||
<span>Playing with: {partner}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{additionalNotes && (
|
||||
<p className='text-xs text-muted-foreground italic bg-muted p-2 rounded'>
|
||||
{additionalNotes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-1 ml-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleEditClick(booking)}
|
||||
disabled={!canModify}
|
||||
className='h-8 w-8 p-0'
|
||||
>
|
||||
<Edit className='h-3 w-3' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleDeleteClick(booking)}
|
||||
disabled={!canModify}
|
||||
className='h-8 w-8 p-0 text-destructive hover:text-destructive/80'
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canModify && (
|
||||
<p className='text-xs text-amber-600 bg-amber-50 p-2 rounded dark:text-amber-400 dark:bg-amber-950'>
|
||||
{settings?.allow_booking_modifications !== 'true'
|
||||
? 'Booking modifications are currently disabled by administrator'
|
||||
: `Booking can only be modified more than ${
|
||||
settings?.booking_modification_hours_before || '2'
|
||||
} hours before the session`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Booking</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
{selectedBooking && (
|
||||
<div className='bg-accent/50 p-4 rounded-lg space-y-2'>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
{formatDate(selectedBooking.date)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<Clock className='h-4 w-4' />
|
||||
{selectedBooking.startTime} - {selectedBooking.endTime}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<MapPin className='h-4 w-4' />
|
||||
{selectedBooking.court.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='edit-partner'>Playing Partner</Label>
|
||||
<div className='relative'>
|
||||
<User className='absolute left-3 top-3 h-4 w-4 text-muted-foreground' />
|
||||
<Input
|
||||
id='edit-partner'
|
||||
placeholder='Who will you be playing with?'
|
||||
value={editPartner}
|
||||
onChange={(e) => setEditPartner(e.target.value)}
|
||||
className='pl-10'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='edit-notes'>Additional Notes</Label>
|
||||
<Textarea
|
||||
id='edit-notes'
|
||||
placeholder='Any additional information...'
|
||||
value={editNotes}
|
||||
onChange={(e) => setEditNotes(e.target.value)}
|
||||
className='min-h-[80px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 pt-4'>
|
||||
<Button variant='outline' className='flex-1' onClick={() => setEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className='flex-1' onClick={handleEditSave}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Booking</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to cancel this booking? This action cannot be undone.
|
||||
{selectedBooking && (
|
||||
<div className='mt-3 p-3 bg-muted rounded'>
|
||||
<p className='text-sm font-medium'>
|
||||
{selectedBooking.court.name} - {formatDate(selectedBooking.date)}
|
||||
</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{selectedBooking.startTime} - {selectedBooking.endTime}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
Cancel Booking
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CalendarIcon, Clock, MapPin } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const timeSlots = [
|
||||
'09:00',
|
||||
'10:00',
|
||||
'11:00',
|
||||
'12:00',
|
||||
'13:00',
|
||||
'14:00',
|
||||
'15:00',
|
||||
'16:00',
|
||||
'17:00',
|
||||
'18:00',
|
||||
'19:00',
|
||||
'20:00',
|
||||
];
|
||||
|
||||
const courts = [
|
||||
{ id: 'court-1', name: 'Court 1', isActive: true },
|
||||
{ id: 'court-2', name: 'Court 2', isActive: true },
|
||||
];
|
||||
|
||||
export function BookingCalendar() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||
const [selectedCourt, setSelectedCourt] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-IE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const handleBooking = async () => {
|
||||
if (!selectedDate || !selectedSlot || !selectedCourt) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/bookings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
courtId: selectedCourt,
|
||||
date: selectedDate.toISOString().split('T')[0],
|
||||
timeSlot: selectedSlot,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Reset selections and show success
|
||||
setSelectedSlot(null);
|
||||
setSelectedCourt(null);
|
||||
// Show success message
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Booking created successfully!',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: result.error || 'Booking failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Booking error:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while creating the booking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
{/* Calendar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<CalendarIcon className='h-5 w-5' />
|
||||
Select Date
|
||||
</CardTitle>
|
||||
<CardDescription>Choose the date for your table tennis session</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={selectedDate}
|
||||
onSelect={(date) => date && setSelectedDate(date)}
|
||||
disabled={(date) => date < new Date() || date.getDay() === 0} // Disable past dates and Sundays
|
||||
className='rounded-md border'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Time Slots and Courts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Clock className='h-5 w-5' />
|
||||
Available Slots
|
||||
</CardTitle>
|
||||
<CardDescription>{selectedDate ? formatDate(selectedDate) : 'Select a date first'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
{/* Court Selection */}
|
||||
<div>
|
||||
<h4 className='font-medium mb-3 flex items-center gap-2'>
|
||||
<MapPin className='h-4 w-4' />
|
||||
Select Court
|
||||
</h4>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{courts.map((court) => (
|
||||
<Button
|
||||
key={court.id}
|
||||
variant={selectedCourt === court.id ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={() => setSelectedCourt(court.id)}
|
||||
disabled={!court.isActive}
|
||||
>
|
||||
{court.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Slot Selection */}
|
||||
<div>
|
||||
<h4 className='font-medium mb-3 flex items-center gap-2'>
|
||||
<Clock className='h-4 w-4' />
|
||||
Select Time
|
||||
</h4>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
{timeSlots.map((time) => {
|
||||
const isBooked = Math.random() > 0.7; // Simulate some bookings
|
||||
return (
|
||||
<Button
|
||||
key={time}
|
||||
variant={selectedSlot === time ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={() => !isBooked && setSelectedSlot(time)}
|
||||
disabled={isBooked}
|
||||
className={`relative transition-all ${
|
||||
isBooked
|
||||
? 'opacity-60 cursor-not-allowed bg-muted/50 border-muted text-muted-foreground hover:opacity-75'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
{isBooked && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='absolute -top-1 -right-1 h-2 w-2 p-0 bg-muted border-muted'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Summary */}
|
||||
{selectedDate && selectedSlot && selectedCourt && (
|
||||
<div className='bg-primary/5 border border-primary/20 rounded-lg p-4 space-y-2 dark:bg-primary/10 dark:border-primary/30'>
|
||||
<h4 className='font-medium text-primary dark:text-primary-foreground'>Booking Summary</h4>
|
||||
<div className='text-sm text-primary/80 dark:text-primary-foreground/80 space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CalendarIcon className='h-3 w-3' />
|
||||
{formatDate(selectedDate)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Clock className='h-3 w-3' />
|
||||
{selectedSlot} - {String(parseInt(selectedSlot.split(':')[0]) + 1).padStart(2, '0')}
|
||||
:00
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MapPin className='h-3 w-3' />
|
||||
{courts.find((c) => c.id === selectedCourt)?.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleBooking} className='w-full mt-3'>
|
||||
Confirm Booking
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar, Clock, Users, MapPin, TrendingUp, Activity } from 'lucide-react';
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
todayBookings: number;
|
||||
activeCourts: number;
|
||||
userBookings: number;
|
||||
upcomingBookings: number;
|
||||
}
|
||||
|
||||
export function QuickStats() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalUsers: 0,
|
||||
todayBookings: 0,
|
||||
activeCourts: 0,
|
||||
userBookings: 0,
|
||||
upcomingBookings: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/dashboard/stats');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data.stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Card>
|
||||
<CardContent className='p-6'>
|
||||
<div className='animate-pulse'>
|
||||
<div className='h-4 bg-gray-200 rounded w-3/4 mb-4'></div>
|
||||
<div className='space-y-3'>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className='flex justify-between'>
|
||||
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||
<div className='h-3 bg-gray-200 rounded w-1/4'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Card>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Quick Stats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4 text-blue-600' />
|
||||
<span className='text-sm'>Your Bookings</span>
|
||||
</div>
|
||||
<Badge variant='secondary'>{stats.userBookings} active</Badge>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Clock className='h-4 w-4 text-green-600' />
|
||||
<span className='text-sm'>Upcoming</span>
|
||||
</div>
|
||||
<Badge variant='secondary'>{stats.upcomingBookings} bookings</Badge>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MapPin className='h-4 w-4 text-purple-600' />
|
||||
<span className='text-sm'>Active Courts</span>
|
||||
</div>
|
||||
<Badge variant='secondary'>{stats.activeCourts} available</Badge>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Activity className='h-4 w-4 text-orange-600' />
|
||||
<span className='text-sm'>Today\'s Bookings</span>
|
||||
</div>
|
||||
<Badge variant='secondary'>{stats.todayBookings} total</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>System Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Users className='h-4 w-4 text-gray-600' />
|
||||
<span className='text-sm'>Total Users</span>
|
||||
</div>
|
||||
<Badge variant='outline'>{stats.totalUsers}</Badge>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<TrendingUp className='h-4 w-4 text-green-600' />
|
||||
<span className='text-sm'>System Status</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='h-2 w-2 bg-green-500 rounded-full' />
|
||||
<span className='text-xs text-green-600'>Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar, Clock, MapPin } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Booking {
|
||||
id: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
court: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function RecentBookings() {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecentBookings();
|
||||
}, []);
|
||||
|
||||
const fetchRecentBookings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/dashboard/recent-bookings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBookings(data.bookings || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent bookings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return format(date, 'MMM dd');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Recent Bookings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-3'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className='animate-pulse'>
|
||||
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Recent Bookings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bookings.length === 0 ? (
|
||||
<div className='text-sm text-muted-foreground text-center py-6'>
|
||||
No recent bookings yet. Make your first booking!
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{bookings.map((booking) => (
|
||||
<div key={booking.id} className='border border-border rounded-lg p-3 space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MapPin className='h-4 w-4 text-primary' />
|
||||
<span className='font-medium text-sm'>{booking.court.name}</span>
|
||||
</div>
|
||||
<Badge variant={booking.status === 'active' ? 'default' : 'secondary'}>
|
||||
{booking.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-4 text-xs text-muted-foreground'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar className='h-3 w-3' />
|
||||
<span>{formatDate(booking.date)}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
<span>
|
||||
{booking.startTime} - {booking.endTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.notes && (
|
||||
<p className='text-xs text-muted-foreground italic'>{booking.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Bell, LogOut, Settings, User, Calendar } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { NotificationBell, AnnouncementsModal } from '@/components/notifications/announcements';
|
||||
import { UserProfile } from '@/components/user/user-profile';
|
||||
import { ModeToggle } from '@/components/ui/mode-toggle';
|
||||
import type { AppConfig } from '@/lib/app-config';
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
user: {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: 'user' | 'admin';
|
||||
name?: string;
|
||||
surname?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function DashboardHeader({ user }: DashboardHeaderProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [showAnnouncements, setShowAnnouncements] = useState(false);
|
||||
const [showUserProfile, setShowUserProfile] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAppConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
setAppConfig(config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching app config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAppConfig();
|
||||
}, []);
|
||||
|
||||
// Fetch unread announcements count on component mount
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
}, []);
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/announcements');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUnreadCount(data.unreadCount || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching unread count:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Logged out successfully',
|
||||
description: 'See you next time!',
|
||||
});
|
||||
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Logout failed',
|
||||
description: 'Please try again',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className='bg-background/80 backdrop-blur-md border-b border-border sticky top-0 z-50'>
|
||||
<div className='container mx-auto px-4'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center space-x-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Calendar className='h-6 w-6 text-primary' />
|
||||
<h1 className='text-xl font-bold text-foreground'>{appConfig?.clubName || 'TT Booking'}</h1>
|
||||
</div>
|
||||
{user.role === 'admin' && <Badge variant='secondary'>Admin</Badge>}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-4'>
|
||||
<NotificationBell unreadCount={unreadCount} onClick={() => setShowAnnouncements(true)} />
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Button variant='ghost' size='sm' onClick={() => router.push('/admin')}>
|
||||
<Settings className='h-4 w-4 mr-2' />
|
||||
Admin Panel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setShowUserProfile(true)}
|
||||
className='flex items-center space-x-2'
|
||||
>
|
||||
<User className='h-4 w-4 text-muted-foreground' />
|
||||
<span className='text-sm text-foreground'>
|
||||
{user.name && user.surname ? `${user.name} ${user.surname}` : user.email.split('@')[0]}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button variant='outline' size='sm' onClick={handleLogout} disabled={isLoggingOut}>
|
||||
<LogOut className='h-4 w-4 mr-2' />
|
||||
{isLoggingOut ? 'Logging out...' : 'Logout'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Announcements Modal */}
|
||||
<AnnouncementsModal
|
||||
isOpen={showAnnouncements}
|
||||
onClose={() => setShowAnnouncements(false)}
|
||||
unreadCount={unreadCount}
|
||||
onCountUpdate={setUnreadCount}
|
||||
/>
|
||||
|
||||
{/* User Profile Modal */}
|
||||
<Dialog open={showUserProfile} onOpenChange={setShowUserProfile}>
|
||||
<DialogContent className='sm:max-w-4xl max-h-[90vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>User Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UserProfile />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, X, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface AnnouncementsProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
unreadCount: number;
|
||||
onCountUpdate: (count: number) => void;
|
||||
}
|
||||
|
||||
export function AnnouncementsModal({ isOpen, onClose, unreadCount, onCountUpdate }: AnnouncementsProps) {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchAnnouncements();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/announcements');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAnnouncements(data.announcements || []);
|
||||
onCountUpdate(data.unreadCount || 0);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch announcements',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching announcements:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch announcements',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return <AlertCircle className='h-4 w-4 text-destructive' />;
|
||||
case 'medium':
|
||||
return <AlertTriangle className='h-4 w-4 text-amber-500 dark:text-amber-400' />;
|
||||
default:
|
||||
return <Info className='h-4 w-4 text-primary' />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'border-destructive/20 bg-destructive/5';
|
||||
case 'medium':
|
||||
return 'border-amber-500/20 bg-amber-500/5 dark:border-amber-400/20 dark:bg-amber-400/5';
|
||||
default:
|
||||
return 'border-primary/20 bg-primary/5';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-IE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Bell className='h-5 w-5' />
|
||||
Announcements
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant='destructive' className='text-xs'>
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex-1 overflow-y-auto'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-foreground'></div>
|
||||
<p className='ml-2 text-foreground'>Loading announcements...</p>
|
||||
</div>
|
||||
) : announcements.length === 0 ? (
|
||||
<div className='text-center py-8 text-muted-foreground'>
|
||||
<Bell className='h-12 w-12 mx-auto mb-4 text-muted-foreground/50' />
|
||||
<p>No announcements at this time</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{announcements.map((announcement) => (
|
||||
<Card
|
||||
key={announcement.id}
|
||||
className={`${getPriorityColor(announcement.priority)} border-l-4`}
|
||||
>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
{getPriorityIcon(announcement.priority)}
|
||||
{announcement.title}
|
||||
<Badge variant='outline' className='ml-auto text-xs'>
|
||||
{announcement.priority}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<p className='text-sm text-foreground mb-2'>{announcement.content}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{formatDate(announcement.createdAt)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end pt-4 border-t'>
|
||||
<Button onClick={onClose} variant='outline'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Bell button component for header
|
||||
interface NotificationBellProps {
|
||||
unreadCount: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function NotificationBell({ unreadCount, onClick }: NotificationBellProps) {
|
||||
return (
|
||||
<Button variant='ghost' size='sm' onClick={onClick} className='relative'>
|
||||
<Bell className='h-4 w-4' />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs p-0 min-w-[20px]'
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { type ThemeProviderProps } from 'next-themes/dist/types';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
|
||||
// Enhanced hook for theme management with database sync
|
||||
export function useThemeWithSync() {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [userTheme, setUserTheme] = React.useState<'light' | 'dark' | 'system'>('system');
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
fetchUserTheme();
|
||||
}, []);
|
||||
|
||||
const fetchUserTheme = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users/theme');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserTheme(data.themePreference);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user theme preference:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = async (theme: 'light' | 'dark' | 'system') => {
|
||||
try {
|
||||
const response = await fetch('/api/users/theme', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ themePreference: theme }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUserTheme(theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update theme preference:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mounted,
|
||||
theme: userTheme,
|
||||
setTheme: updateTheme,
|
||||
};
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,164 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
weekStartsOn={1} // Monday as first day for Ireland
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString('en-IE', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]',
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
'flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border',
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn('bg-popover absolute inset-0 opacity-0', defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal',
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn('w-[--cell-size] select-none', defaultClassNames.week_number_header),
|
||||
week_number: cn('text-muted-foreground select-none text-[0.8rem]', defaultClassNames.week_number),
|
||||
day: cn(
|
||||
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn('bg-accent rounded-l-md', defaultClassNames.range_start),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot='calendar' ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return <ChevronRightIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn('size-4', className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className='flex size-[--cell-size] items-center justify-center text-center'>
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
data-day={day.date.toLocaleDateString('en-IE')}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const handleThemeChange = async (newTheme: 'light' | 'dark' | 'system') => {
|
||||
// Update next-themes immediately for UI responsiveness
|
||||
setTheme(newTheme);
|
||||
|
||||
// Sync with database in background
|
||||
try {
|
||||
await fetch('/api/users/theme', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ themePreference: newTheme }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sync theme preference:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='icon'>
|
||||
<Sun className='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
|
||||
<Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => handleThemeChange('light')}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleThemeChange('dark')}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleThemeChange('system')}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -1,120 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,113 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=''
|
||||
{...props}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold [&+div]:text-xs', className)} {...props} />
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} />
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className='grid gap-1'>
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST'];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST'];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -1,328 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { User, Edit, Mail, Calendar, Save, X, Palette } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { ModeToggle } from '@/components/ui/mode-toggle';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
themePreference: 'light' | 'dark' | 'system';
|
||||
}
|
||||
|
||||
interface ProfileFormData {
|
||||
name: string;
|
||||
surname: string;
|
||||
}
|
||||
|
||||
export function UserProfile() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState<ProfileFormData>({
|
||||
name: '',
|
||||
surname: '',
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const updateFormData = (field: keyof ProfileFormData, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile();
|
||||
}, []);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users/profile');
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
setUser(userData.user);
|
||||
setFormData({
|
||||
name: userData.user.name,
|
||||
surname: userData.user.surname,
|
||||
});
|
||||
// Sync theme with user preference if available
|
||||
if (userData.user.themePreference && userData.user.themePreference !== theme) {
|
||||
setTheme(userData.user.themePreference);
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch user profile',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch user profile',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim() || !formData.surname.trim()) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Name and surname are required',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/users/profile', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
surname: formData.surname.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Profile updated successfully!',
|
||||
});
|
||||
setIsEditing(false);
|
||||
await fetchUserProfile(); // Refresh user data
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: data.error || 'Failed to update profile',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update profile',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
name: user.name,
|
||||
surname: user.surname,
|
||||
});
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className='p-6'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-primary'></div>
|
||||
<p className='ml-2 text-muted-foreground'>Loading profile...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className='p-6'>
|
||||
<div className='text-center text-muted-foreground'>
|
||||
<p>Unable to load user profile</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<User className='h-5 w-5' />
|
||||
User Profile
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
{/* Profile Information */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
|
||||
{/* Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='name'>First Name</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
id='name'
|
||||
value={formData.name}
|
||||
onChange={(e) => updateFormData('name', e.target.value)}
|
||||
placeholder='Enter your first name'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex items-center gap-2 p-2 bg-muted rounded'>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Surname */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='surname'>Last Name</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
id='surname'
|
||||
value={formData.surname}
|
||||
onChange={(e) => updateFormData('surname', e.target.value)}
|
||||
placeholder='Enter your last name'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex items-center gap-2 p-2 bg-muted rounded'>
|
||||
<span>{user.surname}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email (Read-only) */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>Email Address</Label>
|
||||
<div className='flex items-center gap-2 p-2 bg-muted rounded text-muted-foreground'>
|
||||
<Mail className='h-4 w-4' />
|
||||
<span>{user.email}</span>
|
||||
<span className='text-xs text-muted-foreground/60 ml-auto'>(Read-only)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member Since */}
|
||||
<div className='space-y-2'>
|
||||
<Label>Member Since</Label>
|
||||
<div className='flex items-center gap-2 p-2 bg-muted rounded'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
<span>
|
||||
{new Date(user.createdAt).toLocaleDateString('en-IE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex gap-2 pt-4 border-t'>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button onClick={handleSave} disabled={saving} className='flex items-center gap-2'>
|
||||
<Save className='h-4 w-4' />
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setIsEditing(true)} className='flex items-center gap-2'>
|
||||
<Edit className='h-4 w-4' />
|
||||
Edit Profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Information Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Account Type</Label>
|
||||
<div className='p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-blue-800 dark:text-blue-200 capitalize font-medium'>
|
||||
{user.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>User ID</Label>
|
||||
<div className='p-2 bg-muted rounded text-muted-foreground font-mono text-sm'>
|
||||
{user.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Theme Preferences Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Palette className='h-5 w-5' />
|
||||
Theme Preferences
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<Label>App Theme</Label>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Choose how the app appears to you. System will use your device's theme setting.
|
||||
</p>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
<div className='p-3 bg-muted/50 rounded-lg'>
|
||||
<p className='text-sm'>
|
||||
<strong>Current theme:</strong>{' '}
|
||||
<span className='capitalize'>{theme === 'system' ? 'System preference' : theme}</span>
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground mt-1'>
|
||||
Your theme preference is automatically saved and will be applied across all your
|
||||
sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# LCC Table Tennis Booking - Production Deployment Script
|
||||
# Domain: lcc-tt-booking.mikicvi.com
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting production deployment for LCC Table Tennis Booking..."
|
||||
|
||||
# Check if .env.production exists
|
||||
if [ ! -f .env.production ]; then
|
||||
echo "❌ .env.production file not found!"
|
||||
echo "Please create .env.production with your production environment variables."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create necessary directories
|
||||
echo "📁 Creating necessary directories..."
|
||||
mkdir -p data backups logs
|
||||
|
||||
# Set proper permissions
|
||||
echo "🔒 Setting directory permissions..."
|
||||
chmod 755 data backups logs
|
||||
|
||||
# Pull latest changes (if using git)
|
||||
if [ -d ".git" ]; then
|
||||
echo "📦 Pulling latest changes..."
|
||||
git pull origin main || echo "⚠️ Git pull failed or not needed"
|
||||
fi
|
||||
|
||||
# Setup database
|
||||
echo "🛠️ Setting up the database..."
|
||||
npx tsx scripts/setup-database.ts
|
||||
|
||||
# Build and deploy with Docker Compose
|
||||
echo "🐳 Building and starting Docker containers..."
|
||||
|
||||
# Stop existing containers
|
||||
docker-compose -f docker-compose.production.yml down || echo "No existing containers to stop"
|
||||
|
||||
# Build and start containers
|
||||
docker-compose -f docker-compose.production.yml up -d --build
|
||||
|
||||
# Wait for containers to be healthy
|
||||
echo "⏳ Waiting for containers to be healthy..."
|
||||
sleep 30
|
||||
|
||||
# Check health
|
||||
echo "🔍 Checking application health..."
|
||||
for i in {1..10}; do
|
||||
if curl -f http://localhost:3000/api/health >/dev/null 2>&1; then
|
||||
echo "✅ Application is healthy!"
|
||||
break
|
||||
elif [ $i -eq 10 ]; then
|
||||
echo "❌ Application health check failed after 10 attempts"
|
||||
echo "📋 Container logs:"
|
||||
docker-compose -f docker-compose.production.yml logs tt-booking
|
||||
exit 1
|
||||
else
|
||||
echo "⏳ Attempt $i/10: Application not ready yet, waiting..."
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
|
||||
# Show running containers
|
||||
echo "📊 Running containers:"
|
||||
docker-compose -f docker-compose.production.yml ps
|
||||
|
||||
# Show logs
|
||||
echo "📋 Recent application logs:"
|
||||
docker-compose -f docker-compose.production.yml logs --tail=20 tt-booking
|
||||
|
||||
echo ""
|
||||
echo "🎉 Deployment completed successfully!"
|
||||
echo ""
|
||||
echo "📊 Application Status:"
|
||||
echo " • URL: https://lcc-tt-booking.mikicvi.com"
|
||||
echo " • Health Check: http://localhost:3000/api/health"
|
||||
echo " • Container Status: $(docker-compose -f docker-compose.production.yml ps -q tt-booking | xargs docker inspect -f '{{.State.Status}}')"
|
||||
echo ""
|
||||
echo "🔧 Useful commands:"
|
||||
echo " • View logs: docker-compose -f docker-compose.production.yml logs -f tt-booking"
|
||||
echo " • Restart: docker-compose -f docker-compose.production.yml restart tt-booking"
|
||||
echo " • Stop: docker-compose -f docker-compose.production.yml down"
|
||||
echo ""
|
||||
echo "⚠️ Don't forget to:"
|
||||
echo " 1. Set up Cloudflare Tunnel to expose your application"
|
||||
echo " 2. Update your .env.production with real email credentials"
|
||||
echo " 3. Change the default admin password"
|
||||
echo ""
|
||||
@@ -1,97 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
tt-booking:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: lcc-tt-booking
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=/app/data/sqlite.db
|
||||
- NEXTAUTH_URL=https://lcc-tt-booking.mikicvi.com
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- EMAIL_USER=${EMAIL_USER}
|
||||
- EMAIL_PASSWORD=${EMAIL_PASSWORD}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- PORT=3000
|
||||
- RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-100}
|
||||
- RATE_LIMIT_WINDOW=${RATE_LIMIT_WINDOW:-900000}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- lcc-network
|
||||
|
||||
# Automated backup service
|
||||
backup:
|
||||
image: alpine:latest
|
||||
container_name: lcc-backup
|
||||
volumes:
|
||||
- ./data:/data:ro
|
||||
- ./backups:/backups
|
||||
environment:
|
||||
- TZ=Europe/Dublin
|
||||
command: >
|
||||
sh -c "
|
||||
apk add --no-cache tzdata &&
|
||||
while true; do
|
||||
timestamp=$$(date +%Y%m%d-%H%M%S)
|
||||
echo \"Creating backup at $$timestamp\"
|
||||
cp /data/sqlite.db \"/backups/sqlite-$$timestamp.db\" 2>/dev/null || echo 'No database file found yet'
|
||||
# Keep backups for 30 days
|
||||
find /backups -name 'sqlite-*.db' -mtime +30 -delete 2>/dev/null || true
|
||||
echo \"Backup completed, sleeping for 24 hours\"
|
||||
sleep 86400
|
||||
done"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- tt-booking
|
||||
networks:
|
||||
- lcc-network
|
||||
|
||||
# Log rotation service
|
||||
logrotate:
|
||||
image: alpine:latest
|
||||
container_name: lcc-logrotate
|
||||
volumes:
|
||||
- ./logs:/logs
|
||||
command: >
|
||||
sh -c "
|
||||
apk add --no-cache logrotate &&
|
||||
echo '/logs/*.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
}' > /etc/logrotate.d/app &&
|
||||
while true; do
|
||||
logrotate /etc/logrotate.d/app
|
||||
sleep 86400
|
||||
done"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- lcc-network
|
||||
|
||||
networks:
|
||||
lcc-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
lcc-data:
|
||||
driver: local
|
||||
lcc-backups:
|
||||
driver: local
|
||||
@@ -1,32 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
tt-booking:
|
||||
build: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=/app/data/sqlite.db
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- NEXTAUTH_SECRET=your-secret-key-here-make-this-very-long-and-random
|
||||
- EMAIL_USER=your-email@gmail.com
|
||||
- EMAIL_PASSWORD=your-app-password-here
|
||||
- ADMIN_EMAIL=admin@example.com
|
||||
- ADMIN_PASSWORD=admin123
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
# Nginx reverse proxy (optional, for production deployment)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- tt-booking
|
||||
restart: unless-stopped
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { Config } from 'drizzle-kit';
|
||||
|
||||
export default {
|
||||
schema: './lib/db/schema.ts',
|
||||
out: './lib/db/migrations',
|
||||
driver: 'better-sqlite',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || './data/sqlite.db',
|
||||
},
|
||||
} satisfies Config;
|
||||
@@ -1,189 +0,0 @@
|
||||
'use client';
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from 'react';
|
||||
|
||||
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST'];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST'];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -1,101 +0,0 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { activityLogs } from '@/lib/db/schema';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export interface ActivityLogData {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
details?: any;
|
||||
request?: NextRequest;
|
||||
}
|
||||
|
||||
export async function logActivity({ userId, action, entityType, entityId, details, request }: ActivityLogData) {
|
||||
try {
|
||||
// Extract IP and User Agent from request if provided
|
||||
let ipAddress: string | null = null;
|
||||
let userAgent: string | null = null;
|
||||
|
||||
if (request) {
|
||||
// Try to get real IP address
|
||||
ipAddress =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
request.headers.get('cf-connecting-ip') ||
|
||||
'127.0.0.1';
|
||||
|
||||
userAgent = request.headers.get('user-agent');
|
||||
}
|
||||
|
||||
await db.insert(activityLogs).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
details: details ? JSON.stringify(details) : null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Activity logged: ${action} on ${entityType}${entityId ? ` (${entityId})` : ''} by user ${
|
||||
userId || 'anonymous'
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to log activity:', error);
|
||||
// Don't throw error to avoid breaking the main request
|
||||
}
|
||||
}
|
||||
|
||||
// Predefined action types for consistency
|
||||
export const ACTIONS = {
|
||||
// User actions
|
||||
USER_LOGIN: 'login',
|
||||
USER_LOGOUT: 'logout',
|
||||
USER_REGISTER: 'register',
|
||||
USER_CREATE: 'create_user',
|
||||
USER_UPDATE: 'update_user',
|
||||
USER_DELETE: 'delete_user',
|
||||
|
||||
// Booking actions
|
||||
BOOKING_CREATE: 'create_booking',
|
||||
BOOKING_UPDATE: 'update_booking',
|
||||
BOOKING_CANCEL: 'cancel_booking',
|
||||
BOOKING_DELETE: 'delete_booking',
|
||||
|
||||
// Court actions
|
||||
COURT_CREATE: 'create_court',
|
||||
COURT_UPDATE: 'update_court',
|
||||
COURT_DELETE: 'delete_court',
|
||||
|
||||
// Announcement actions
|
||||
ANNOUNCEMENT_CREATE: 'create_announcement',
|
||||
ANNOUNCEMENT_UPDATE: 'update_announcement',
|
||||
ANNOUNCEMENT_DELETE: 'delete_announcement',
|
||||
|
||||
// Settings actions
|
||||
SETTINGS_UPDATE: 'update_settings',
|
||||
|
||||
// Time slot actions
|
||||
TIME_SLOT_CREATE: 'create_time_slot',
|
||||
TIME_SLOT_UPDATE: 'update_time_slot',
|
||||
TIME_SLOT_DELETE: 'delete_time_slot',
|
||||
|
||||
// System actions
|
||||
SYSTEM_START: 'system_start',
|
||||
SYSTEM_ERROR: 'system_error',
|
||||
} as const;
|
||||
|
||||
export const ENTITY_TYPES = {
|
||||
USER: 'user',
|
||||
BOOKING: 'booking',
|
||||
COURT: 'court',
|
||||
ANNOUNCEMENT: 'announcement',
|
||||
SETTINGS: 'settings',
|
||||
TIME_SLOT: 'time_slot',
|
||||
SYSTEM: 'system',
|
||||
} as const;
|
||||
@@ -1,91 +0,0 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { settings } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export interface AppConfig {
|
||||
clubName: string;
|
||||
sportName: string;
|
||||
appTitle: string;
|
||||
appDescription: string;
|
||||
}
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
clubName: 'TT Club',
|
||||
sportName: 'Table Tennis',
|
||||
appTitle: 'Table Tennis Booking System',
|
||||
appDescription: 'Book your table tennis court slots with ease',
|
||||
};
|
||||
|
||||
let cachedConfig: AppConfig | null = null;
|
||||
let cacheTime: number = 0;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get app configuration with caching
|
||||
* This function fetches the club/brand settings from the database
|
||||
*/
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached config if still valid
|
||||
if (cachedConfig && now - cacheTime < CACHE_DURATION) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch all brand/club settings
|
||||
const configSettings = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, 'club_name'))
|
||||
.union(db.select().from(settings).where(eq(settings.key, 'sport_name')))
|
||||
.union(db.select().from(settings).where(eq(settings.key, 'app_title')))
|
||||
.union(db.select().from(settings).where(eq(settings.key, 'app_description')));
|
||||
|
||||
// Build config object
|
||||
const config: AppConfig = { ...defaultConfig };
|
||||
|
||||
configSettings.forEach((setting) => {
|
||||
switch (setting.key) {
|
||||
case 'club_name':
|
||||
config.clubName = setting.value;
|
||||
break;
|
||||
case 'sport_name':
|
||||
config.sportName = setting.value;
|
||||
break;
|
||||
case 'app_title':
|
||||
config.appTitle = setting.value;
|
||||
break;
|
||||
case 'app_description':
|
||||
config.appDescription = setting.value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result
|
||||
cachedConfig = config;
|
||||
cacheTime = now;
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('Error fetching app config:', error);
|
||||
// Return default config on error
|
||||
return defaultConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache (call when settings are updated)
|
||||
*/
|
||||
export function invalidateAppConfigCache() {
|
||||
cachedConfig = null;
|
||||
cacheTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app config for client components (no database access)
|
||||
* This should be used with server-side rendered props
|
||||
*/
|
||||
export function getDefaultAppConfig(): AppConfig {
|
||||
return { ...defaultConfig };
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
import { users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateId } from '@/lib/utils';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
export async function createUser(data: {
|
||||
email: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
password: string;
|
||||
role?: 'user' | 'admin';
|
||||
}) {
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
const now = new Date();
|
||||
|
||||
const newUser = {
|
||||
id: generateId(),
|
||||
email: data.email.toLowerCase(),
|
||||
name: data.name,
|
||||
surname: data.surname,
|
||||
password: hashedPassword,
|
||||
role: data.role || 'user',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const [user] = await db.insert(users).values(newUser).returning();
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(users).where(eq(users.email, email.toLowerCase()));
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||
return user;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Get database path from environment variable or use default
|
||||
const dbPath = process.env.DATABASE_URL || './data/sqlite.db';
|
||||
const sqlite = new Database(dbPath);
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
// Only run migrations if explicitly requested
|
||||
if (process.env.RUN_MIGRATIONS === 'true') {
|
||||
try {
|
||||
migrate(db, { migrationsFolder: './lib/db/migrations' });
|
||||
console.log('Database migrations completed');
|
||||
} catch (error) {
|
||||
console.error('Database migration failed:', error);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
CREATE TABLE `activity_logs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text,
|
||||
`action` text NOT NULL,
|
||||
`entity_type` text NOT NULL,
|
||||
`entity_id` text,
|
||||
`details` text,
|
||||
`ip_address` text,
|
||||
`user_agent` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `announcements` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`priority` text DEFAULT 'medium' NOT NULL,
|
||||
`expires_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `bookings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`court_id` text NOT NULL,
|
||||
`date` text NOT NULL,
|
||||
`start_time` text NOT NULL,
|
||||
`end_time` text NOT NULL,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`notes` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`court_id`) REFERENCES `courts`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `courts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `settings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`key` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `time_slots` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`day_of_week` integer NOT NULL,
|
||||
`start_time` text NOT NULL,
|
||||
`end_time` text NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`surname` text NOT NULL,
|
||||
`password` text NOT NULL,
|
||||
`role` text DEFAULT 'user' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `settings_key_unique` ON `settings` (`key`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
|
||||
@@ -1,8 +0,0 @@
|
||||
CREATE TABLE `metrics` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`metric_type` text NOT NULL,
|
||||
`period` text NOT NULL,
|
||||
`value` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
@@ -1,497 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "55393d37-4cdf-45ba-aa6a-1e50b082b57c",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"activity_logs": {
|
||||
"name": "activity_logs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"activity_logs_user_id_users_id_fk": {
|
||||
"name": "activity_logs_user_id_users_id_fk",
|
||||
"tableFrom": "activity_logs",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"announcements": {
|
||||
"name": "announcements",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"bookings": {
|
||||
"name": "bookings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"court_id": {
|
||||
"name": "court_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"start_time": {
|
||||
"name": "start_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_time": {
|
||||
"name": "end_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"bookings_user_id_users_id_fk": {
|
||||
"name": "bookings_user_id_users_id_fk",
|
||||
"tableFrom": "bookings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"bookings_court_id_courts_id_fk": {
|
||||
"name": "bookings_court_id_courts_id_fk",
|
||||
"tableFrom": "bookings",
|
||||
"tableTo": "courts",
|
||||
"columnsFrom": [
|
||||
"court_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"courts": {
|
||||
"name": "courts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"settings_key_unique": {
|
||||
"name": "settings_key_unique",
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"time_slots": {
|
||||
"name": "time_slots",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"day_of_week": {
|
||||
"name": "day_of_week",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"start_time": {
|
||||
"name": "start_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_time": {
|
||||
"name": "end_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"surname": {
|
||||
"name": "surname",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
}
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "b6ff7034-4299-4b61-8a16-3b46eae7b4ef",
|
||||
"prevId": "55393d37-4cdf-45ba-aa6a-1e50b082b57c",
|
||||
"tables": {
|
||||
"activity_logs": {
|
||||
"name": "activity_logs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"activity_logs_user_id_users_id_fk": {
|
||||
"name": "activity_logs_user_id_users_id_fk",
|
||||
"tableFrom": "activity_logs",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"announcements": {
|
||||
"name": "announcements",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"bookings": {
|
||||
"name": "bookings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"court_id": {
|
||||
"name": "court_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"start_time": {
|
||||
"name": "start_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_time": {
|
||||
"name": "end_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"bookings_user_id_users_id_fk": {
|
||||
"name": "bookings_user_id_users_id_fk",
|
||||
"tableFrom": "bookings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"bookings_court_id_courts_id_fk": {
|
||||
"name": "bookings_court_id_courts_id_fk",
|
||||
"tableFrom": "bookings",
|
||||
"tableTo": "courts",
|
||||
"columnsFrom": [
|
||||
"court_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"courts": {
|
||||
"name": "courts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"metrics": {
|
||||
"name": "metrics",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metric_type": {
|
||||
"name": "metric_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"period": {
|
||||
"name": "period",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"settings_key_unique": {
|
||||
"name": "settings_key_unique",
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"time_slots": {
|
||||
"name": "time_slots",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"day_of_week": {
|
||||
"name": "day_of_week",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"start_time": {
|
||||
"name": "start_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_time": {
|
||||
"name": "end_time",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"surname": {
|
||||
"name": "surname",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user