Compare commits

..

1 Commits

Author SHA1 Message Date
mikicv 368d1645ce Initial commit 2025-09-28 14:03:22 +01:00
126 changed files with 142 additions and 27522 deletions
-11
View File
@@ -1,11 +0,0 @@
# Docker ignore file
node_modules
.next
.git
.env.local
.env.development
*.log
npm-debug.log*
.DS_Store
*.tgz
*.tar.gz
-14
View File
@@ -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"
-28
View File
@@ -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
View File
@@ -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.*
-279
View File
@@ -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.
-506
View File
@@ -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
View File
@@ -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"]
-74
View File
@@ -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"]
+18
View File
@@ -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.
-149
View File
@@ -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! 🏓
+2 -200
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
import { AdminDashboard } from '@/components/admin/admin-dashboard';
export default function AdminPage() {
return <AdminDashboard />;
}
-78
View File
@@ -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 });
}
}
-63
View File
@@ -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 });
}
}
-75
View File
@@ -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 });
}
}
-59
View File
@@ -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 });
}
}
-93
View File
@@ -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 });
}
}
-52
View File
@@ -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 });
}
}
-82
View File
@@ -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 });
}
}
-68
View File
@@ -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 });
}
}
-103
View File
@@ -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 });
}
}
-85
View File
@@ -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 });
}
}
-92
View File
@@ -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 });
}
}
-83
View File
@@ -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 });
}
}
-36
View File
@@ -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 });
}
}
-65
View File
@@ -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 });
}
}
-7
View File
@@ -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' });
}
-70
View File
@@ -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 });
}
}
-193
View File
@@ -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 });
}
}
-56
View File
@@ -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 });
}
}
-261
View File
@@ -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 });
}
}
-12
View File
@@ -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 });
}
}
-24
View File
@@ -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 });
}
}
-57
View File
@@ -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 });
}
}
-43
View File
@@ -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 }
);
}
}
-23
View File
@@ -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 });
}
}
-28
View File
@@ -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 });
}
}
-104
View File
@@ -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 });
}
}
-64
View File
@@ -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 });
}
}
-72
View File
@@ -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
View File
@@ -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);
}
}
-30
View File
@@ -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>
);
}
-27
View File
@@ -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>
);
}
-16
View File
@@ -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');
}
}
-27
View File
@@ -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>
);
}
-40
View File
@@ -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
-22
View File
@@ -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>
);
}
-386
View File
@@ -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>
);
}
-175
View File
@@ -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>
);
}
-152
View File
@@ -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>
);
}
-548
View File
@@ -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>
);
}
-183
View File
@@ -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>
);
}
-133
View File
@@ -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>
);
}
-191
View File
@@ -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>
</>
);
}
-208
View File
@@ -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>
);
}
-135
View File
@@ -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>
);
}
-128
View File
@@ -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>
);
}
-155
View File
@@ -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>
);
}
-181
View File
@@ -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>
);
}
-56
View File
@@ -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,
};
}
-106
View File
@@ -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,
};
-30
View File
@@ -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 };
-47
View File
@@ -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 };
-164
View File
@@ -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 };
-43
View File
@@ -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 };
-122
View File
@@ -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,
}
-201
View File
@@ -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,
}
-178
View File
@@ -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,
}
-22
View File
@@ -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 };
-26
View File
@@ -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 }
-52
View File
@@ -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>
);
}
-159
View File
@@ -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,
}
-29
View File
@@ -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 }
-120
View File
@@ -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,
}
-55
View File
@@ -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 };
-22
View File
@@ -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 }
-113
View File
@@ -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,
};
-26
View File
@@ -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>
);
}
-187
View File
@@ -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 };
-328
View File
@@ -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>
);
}
-90
View File
@@ -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 ""
-97
View File
@@ -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
-32
View File
@@ -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
-10
View File
@@ -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;
-189
View File
@@ -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 };
-101
View File
@@ -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;
-91
View File
@@ -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
View File
@@ -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;
}
-19
View File
@@ -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);
}
}
-77
View File
@@ -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
);
-497
View File
@@ -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": {}
}
}
-549
View File
@@ -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