initial version of the app

This commit is contained in:
mikicvi
2025-09-21 17:11:02 +01:00
commit c8062cf96b
101 changed files with 23061 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 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"
+52
View File
@@ -0,0 +1,52 @@
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Next.js
.next/
out/
# Dependencies
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.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/
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
+38
View File
@@ -0,0 +1,38 @@
# 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"]
+201
View File
@@ -0,0 +1,201 @@
# Table Tennis Booking System
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.
+5
View File
@@ -0,0 +1,5 @@
import { AdminDashboard } from '@/components/admin/admin-dashboard';
export default function AdminPage() {
return <AdminDashboard />;
}
+76
View File
@@ -0,0 +1,76 @@
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, { params }: { params: { 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 announcementId = params.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, { params }: { params: { id: string } }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const announcementId = params.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
@@ -0,0 +1,63 @@
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 });
}
}
+73
View File
@@ -0,0 +1,73 @@
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, { params }: { params: { 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 courtId = params.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, { params }: { params: { id: string } }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const courtId = params.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
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,93 @@
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
@@ -0,0 +1,52 @@
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 });
}
}
+78
View File
@@ -0,0 +1,78 @@
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';
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);
return NextResponse.json({ message: 'Settings updated successfully' });
} catch (error) {
console.error('Error updating settings:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+90
View File
@@ -0,0 +1,90 @@
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, { params }: { params: { 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 userId = params.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, { params }: { params: { id: string } }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = params.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
@@ -0,0 +1,83 @@
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
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,70 @@
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 });
}
}
+145
View File
@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { db } from '@/lib/db';
import { bookings } 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, { params }: { params: { id: string } }) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { notes } = await request.json();
const bookingId = params.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 can be modified (more than 2 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 <= 2) {
return NextResponse.json(
{ error: 'Booking can only be modified more than 2 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, { params }: { params: { id: string } }) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const bookingId = params.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 can be cancelled (more than 2 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 <= 2) {
return NextResponse.json(
{ error: 'Booking can only be cancelled more than 2 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 });
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { bookings, courts } from '@/lib/db/schema';
import { eq, and } 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 });
}
const userBookings = await db
.select({
id: bookings.id,
courtId: bookings.courtId,
date: bookings.date,
startTime: bookings.startTime,
endTime: bookings.endTime,
status: bookings.status,
createdAt: bookings.createdAt,
})
.from(bookings)
.where(eq(bookings.userId, session.userId));
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 } = 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 }
);
}
// 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',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
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 });
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { bookings, courts } from '@/lib/db/schema';
import { eq, and } 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 });
}
const userBookings = await db
.select({
id: bookings.id,
courtId: bookings.courtId,
date: bookings.date,
startTime: bookings.startTime,
endTime: bookings.endTime,
status: bookings.status,
createdAt: bookings.createdAt,
})
.from(bookings)
.where(eq(bookings.userId, session.userId));
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 } = 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 }
);
}
// 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',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
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 });
}
}
+152
View File
@@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { bookings, courts } from '@/lib/db/schema';
import { eq, and } 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 });
}
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(eq(bookings.userId, session.userId));
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 } = 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 }
);
}
// 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',
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,
});
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 });
}
}
+24
View File
@@ -0,0 +1,24 @@
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 });
}
}
@@ -0,0 +1,55 @@
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
@@ -0,0 +1,57 @@
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 });
}
}
+23
View File
@@ -0,0 +1,23 @@
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 });
}
}
+103
View File
@@ -0,0 +1,103 @@
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,
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 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { EnhancedBookingCalendar } from '@/components/booking/enhanced-booking-calendar';
import { UserBookingManagement } from '@/components/booking/user-booking-management';
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
redirect('/login');
}
return (
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100'>
<DashboardHeader user={session} />
<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-gray-900 mb-2'>
Welcome back, {session.email.split('@')[0]}! 🏓
</h1>
<p className='text-gray-600'>Book your table tennis court and enjoy your game</p>
</div>
<EnhancedBookingCalendar />
</div>
{/* Sidebar */}
<div className='space-y-6'>
<UserBookingManagement />
</div>
</div>
</main>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+25
View File
@@ -0,0 +1,25 @@
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';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Table Tennis Booking System',
description: 'Book your table tennis court slots with ease',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en' suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider attribute='class' defaultTheme='light' enableSystem disableTransitionOnChange>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Link from 'next/link';
import { LoginForm } from '@/components/auth/LoginForm';
export default function LoginPage() {
return (
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 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-gray-900 mb-2'>🏓 TT Booking</h1>
<p className='text-gray-600'>Professional table tennis court booking system</p>
</div>
<LoginForm />
<div className='text-center text-sm'>
<span className='text-gray-600'>Don't have an account? </span>
<Link href='/register' className='text-blue-600 hover:text-blue-800 font-medium'>
Sign up
</Link>
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
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');
}
}
+24
View File
@@ -0,0 +1,24 @@
import Link from 'next/link';
import { RegisterForm } from '@/components/auth/RegisterForm';
export default function RegisterPage() {
return (
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 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-gray-900 mb-2'>🏓 TT Booking</h1>
<p className='text-gray-600'>Join our table tennis community</p>
</div>
<RegisterForm />
<div className='text-center text-sm'>
<span className='text-gray-600'>Already have an account? </span>
<Link href='/login' className='text-blue-600 hover:text-blue-800 font-medium'>
Sign in
</Link>
</div>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
{
"$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": {}
}
@@ -0,0 +1,544 @@
'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 { 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 [editingAnnouncement, setEditingAnnouncement] = 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 handleDeleteAnnouncement = async (announcementId: string) => {
try {
if (!confirm('Are you sure you want to delete this announcement?')) {
return;
}
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-red-600 bg-red-50';
case 'medium':
return 'text-yellow-600 bg-yellow-50';
case 'low':
return 'text-green-600 bg-green-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
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'>{announcement.title}</div>
<div className='text-sm text-gray-500 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-gray-500' />
{announcement.expiresAt
? new Date(announcement.expiresAt).toLocaleDateString()
: 'Never'}
</div>
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Calendar className='h-4 w-4 text-gray-500' />
{new Date(announcement.createdAt).toLocaleDateString()}
</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={() => handleDeleteAnnouncement(announcement.id)}
className='text-red-600 hover:text-red-700'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{announcements.length === 0 && (
<div className='text-center py-8 text-gray-500'>
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>
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
'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 { 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 [editingCourt, setEditingCourt] = 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 handleDelete = async (courtId: string) => {
if (!confirm('Are you sure you want to delete this court? This action cannot be undone.')) {
return;
}
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()}
</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={() => handleDelete(court.id)}
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>
</Card>
);
}
+175
View File
@@ -0,0 +1,175 @@
'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
@@ -0,0 +1,152 @@
'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>
);
}
@@ -0,0 +1,295 @@
'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 {
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;
}
export function AdminSettingsManagement() {
const [settings, setSettings] = useState<SettingsData>({
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',
});
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 = {
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',
};
// 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'>
<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>
{/* 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 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>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
+484
View File
@@ -0,0 +1,484 @@
'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 { 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 [editingUser, setEditingUser] = 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 () => {
try {
if (!formData.name || !formData.surname || !formData.email || !formData.password) {
toast({
title: 'Error',
description: 'Please fill in all required fields',
variant: 'destructive',
});
return;
}
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',
});
}
};
const handleEditUser = async () => {
try {
if (!editingUser || !formData.name || !formData.surname || !formData.email) {
toast({
title: 'Error',
description: 'Please fill in all required fields',
variant: 'destructive',
});
return;
}
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',
});
}
};
const handleDeleteUser = async (userId: string) => {
try {
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
return;
}
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>
<div 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 variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateUser}>Create User</Button>
</div>
</div>
</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()}
</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={() => handleDeleteUser(user.id)}
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}>Update User</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
'use client';
import { useState } 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';
export function AdminDashboard() {
const router = useRouter();
const [stats] = useState({
totalUsers: 125,
todayBookings: 18,
totalCourts: 2,
weeklyRevenue: 850,
});
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-gray-50'>
{/* Header */}
<header className='bg-white border-b border-gray-200'>
<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' />
<h1 className='text-xl font-semibold text-gray-900'>Admin Dashboard</h1>
</div>
<div className='flex items-center space-x-4'>
<Badge variant='secondary' className='bg-blue-100 text-blue-800'>
Administrator
</Badge>
<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'>{stats.totalUsers}</div>
<p className='text-xs text-muted-foreground'>+12% from last month</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'>{stats.todayBookings}</div>
<p className='text-xs text-muted-foreground'>+5% from yesterday</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'>{stats.totalCourts}</div>
<p className='text-xs text-muted-foreground'>All courts operational</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Weekly Revenue</CardTitle>
<BarChart3 className='h-4 w-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>${stats.weeklyRevenue}</div>
<p className='text-xs text-muted-foreground'>+8% from last week</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'>
<AdminSettingsManagement />
</TabsContent>
<TabsContent value='announcements'>
<AdminAnnouncementManagement />
</TabsContent>{' '}
<TabsContent value='logs'>
<AdminLogs />
</TabsContent>
</Tabs>
</main>
</div>
);
}
@@ -0,0 +1,123 @@
'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-red-500' />;
case 'medium':
return <AlertTriangle className='h-4 w-4 text-yellow-500' />;
default:
return <Info className='h-4 w-4 text-blue-500' />;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'bg-red-100 text-red-800 border-red-200';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-blue-100 text-blue-800 border-blue-200';
}
};
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-gray-50'>
<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'>{announcement.title}</h4>
<p className='text-sm text-gray-600'>{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-gray-500'>
<Bell className='h-8 w-8 mx-auto mb-2 opacity-30' />
<p>No announcements at this time</p>
</div>
)}
</div>
</CardContent>
</Card>
);
}
+133
View File
@@ -0,0 +1,133 @@
'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
@@ -0,0 +1,191 @@
'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>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
export function LoginForm() {
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
toast({
title: 'Success',
description: 'Logged in successfully',
});
if (data.user.role === 'admin') {
router.push('/admin');
} else {
router.push('/dashboard');
}
} else {
toast({
title: 'Error',
description: data.error || 'Login failed',
variant: 'destructive',
});
}
} catch (error) {
toast({
title: 'Error',
description: 'An unexpected error occurred',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Card className='w-full max-w-md mx-auto'>
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your email and password to access your account</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
type='email'
placeholder='Enter your email'
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className='space-y-2'>
<Label htmlFor='password'>Password</Label>
<Input
id='password'
type='password'
placeholder='Enter your password'
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className='mt-4 text-center'>
<Button variant='link' onClick={() => router.push('/register')} className='text-sm'>
Don't have an account? Sign up
</Button>
</div>
</CardContent>
</Card>
);
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
export function RegisterForm() {
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
email: '',
name: '',
surname: '',
password: '',
confirmPassword: '',
});
const router = useRouter();
const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (formData.password !== formData.confirmPassword) {
toast({
title: 'Error',
description: 'Passwords do not match',
variant: 'destructive',
});
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
name: formData.name,
surname: formData.surname,
password: formData.password,
}),
});
const data = await response.json();
if (response.ok) {
toast({
title: 'Success',
description: 'Account created successfully! Please log in.',
});
router.push('/');
} else {
toast({
title: 'Error',
description: data.error || 'Registration failed',
variant: 'destructive',
});
}
} catch (error) {
toast({
title: 'Error',
description: 'An unexpected error occurred',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
return (
<Card className='w-full max-w-md mx-auto'>
<CardHeader>
<CardTitle>Create Account</CardTitle>
<CardDescription>Fill in your details to create a new account</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-2'>
<Label htmlFor='name'>First Name</Label>
<Input
id='name'
type='text'
placeholder='John'
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
required
/>
</div>
<div className='space-y-2'>
<Label htmlFor='surname'>Last Name</Label>
<Input
id='surname'
type='text'
placeholder='Doe'
value={formData.surname}
onChange={(e) => handleInputChange('surname', e.target.value)}
required
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
type='email'
placeholder='john.doe@example.com'
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
required
/>
</div>
<div className='space-y-2'>
<Label htmlFor='password'>Password</Label>
<Input
id='password'
type='password'
placeholder='Enter your password'
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
required
minLength={6}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>Confirm Password</Label>
<Input
id='confirmPassword'
type='password'
placeholder='Confirm your password'
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
required
minLength={6}
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create Account'}
</Button>
</form>
<div className='mt-4 text-center'>
<Button variant='link' onClick={() => router.push('/')} className='text-sm'>
Already have an account? Sign in
</Button>
</div>
</CardContent>
</Card>
);
}
+290
View File
@@ -0,0 +1,290 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight } 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;
}
interface BookingSlot {
time: string;
courtId: string;
courtName: string;
available: boolean;
bookingId?: string;
}
export function BookingCalendar() {
const [selectedDate, setSelectedDate] = useState(new Date());
const [courts, setCourts] = useState<Court[]>([]);
const [bookings, setBookings] = useState<Booking[]>([]);
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
// Time slots for booking (7 PM to 11 PM)
const timeSlots = ['19:00', '20:00', '21:00', '22:00'];
useEffect(() => {
fetchCourts();
}, []);
useEffect(() => {
if (courts.length > 0) {
fetchBookings();
}
}, [selectedDate, courts]);
const fetchCourts = async () => {
try {
const response = await fetch('/api/admin/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',
});
}
};
const fetchBookings = async () => {
try {
const response = await fetch('/api/bookings');
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 generateBookingSlots = (existingBookings: Booking[]) => {
const dateStr = selectedDate.toISOString().split('T')[0];
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'
);
slots.push({
time,
courtId: court.id,
courtName: court.name,
available: !existingBooking,
bookingId: existingBooking?.id,
});
});
});
setBookingSlots(slots);
};
const handleBookSlot = async (courtId: string, timeSlot: string) => {
setLoading(true);
try {
const dateStr = selectedDate.toISOString().split('T')[0];
const response = await fetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courtId,
date: dateStr,
timeSlot,
}),
});
const data = await response.json();
if (response.ok) {
toast({
title: 'Success',
description: 'Booking created successfully!',
});
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));
setSelectedDate(newDate);
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isPastDate = (date: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
};
return (
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Calendar className='h-5 w-5' />
Book Your Court
</CardTitle>
</CardHeader>
<CardContent>
<div className='space-y-6'>
{/* Date Navigation */}
<div className='flex items-center justify-between'>
<Button
variant='outline'
size='sm'
onClick={() => navigateDate('prev')}
disabled={isPastDate(new Date(selectedDate.getTime() - 24 * 60 * 60 * 1000))}
>
<ChevronLeft className='h-4 w-4' />
Previous Day
</Button>
<h3 className='text-lg font-semibold'>
{selectedDate.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
{isToday(selectedDate) && <span className='text-sm text-blue-600 ml-2'>(Today)</span>}
</h3>
<Button variant='outline' size='sm' onClick={() => navigateDate('next')}>
Next Day
<ChevronRight className='h-4 w-4' />
</Button>
</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-gray-900'></div>
<p className='mt-2 text-sm text-gray-500'>Loading booking slots...</p>
</div>
)}
{/* No Courts Available */}
{!loading && courts.length === 0 && (
<div className='text-center py-8'>
<p className='text-gray-500'>No courts available for booking</p>
</div>
)}
{/* Past Date Warning */}
{isPastDate(selectedDate) && (
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-4'>
<p className='text-yellow-800 text-sm'>
You cannot book courts for past dates. Please select a current or future date.
</p>
</div>
)}
{/* Time Slots Grid */}
{!loading && courts.length > 0 && !isPastDate(selectedDate) && (
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
{bookingSlots.map((slot, index) => (
<div
key={`${slot.courtId}-${slot.time}`}
className={`p-4 border rounded-lg transition-colors ${
slot.available
? 'border-green-200 bg-green-50 hover:bg-green-100'
: 'border-red-200 bg-red-50'
}`}
>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<div className='flex items-center gap-2 text-sm font-medium'>
<Clock className='h-4 w-4' />
{slot.time} -{' '}
{String(parseInt(slot.time.split(':')[0]) + 1).padStart(2, '0')}:00
</div>
<div className='flex items-center gap-2 text-sm text-gray-600'>
<MapPin className='h-4 w-4' />
{slot.courtName}
</div>
{!slot.available && (
<div className='text-xs text-red-600'>Already booked</div>
)}
</div>
<Button
size='sm'
disabled={!slot.available || loading}
onClick={() => handleBookSlot(slot.courtId, slot.time)}
className={slot.available ? 'bg-green-600 hover:bg-green-700' : ''}
>
{slot.available ? 'Book' : 'Booked'}
</Button>
</div>
</div>
))}
</div>
)}
{/* No Slots Message */}
{!loading && courts.length > 0 && bookingSlots.length === 0 && !isPastDate(selectedDate) && (
<div className='text-center py-8'>
<p className='text-gray-500'>No booking slots available for this date</p>
</div>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,525 @@
'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;
}
interface BookingSlot {
time: string;
courtId: string;
courtName: string;
available: boolean;
bookingId?: string;
}
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 [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();
}, []);
useEffect(() => {
if (courts.length > 0 && settings) {
fetchBookings();
}
}, [selectedDate, courts, settings]);
// 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',
});
}
};
const fetchBookings = async () => {
try {
const response = await fetch('/api/bookings');
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[] => {
if (!settings) return [];
const start = parseInt(settings.booking_start_time.split(':')[0]);
const end = parseInt(settings.booking_end_time.split(':')[0]);
const slots = [];
for (let hour = start; hour < end; hour++) {
slots.push(`${hour.toString().padStart(2, '0')}:00`);
}
return slots;
};
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'
);
slots.push({
time,
courtId: court.id,
courtName: court.name,
available: !existingBooking,
bookingId: existingBooking?.id,
});
});
});
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;
// Check weekend restrictions
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;
setSelectedSlot(slot);
setPartnerName('');
setNotes('');
setShowBookingDialog(true);
};
const handleBookingConfirm = async () => {
if (!selectedSlot) 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) => (
<Button
key={index}
variant={
date.toDateString() === selectedDate.toDateString()
? 'default'
: 'outline'
}
size='sm'
onClick={() => setSelectedDate(date)}
className='h-16 flex flex-col'
>
<span className='text-xs font-normal'>
{date.toLocaleDateString('en-US', { weekday: 'short' })}
</span>
<span className='font-semibold'>{date.getDate()}</span>
<span className='text-xs font-normal'>
{date.toLocaleDateString('en-US', { month: 'short' })}
</span>
{isToday(date) && <span className='text-xs text-blue-600'>Today</span>}
</Button>
))}
</div>
</div>
{/* Selected Date Display */}
<div className='text-center p-4 bg-blue-50 rounded-lg'>
<h3 className='text-lg font-semibold text-blue-900'>
{selectedDate.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</h3>
{isToday(selectedDate) && <span className='text-sm text-blue-600'>Today</span>}
</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-gray-900'></div>
<p className='mt-2 text-sm text-gray-500'>Loading booking slots...</p>
</div>
)}
{/* No Courts Available */}
{!loading && courts.length === 0 && (
<div className='text-center py-8'>
<p className='text-gray-500'>No courts available for booking</p>
</div>
)}
{/* Time Slots Grid */}
{!loading && courts.length > 0 && (
<div className='space-y-4'>
<h3 className='font-medium'>Available Time Slots</h3>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
{bookingSlots.map((slot, index) => (
<div
key={`${slot.courtId}-${slot.time}`}
className={`p-4 border rounded-lg transition-colors cursor-pointer ${
slot.available
? 'border-green-200 bg-green-50 hover:bg-green-100'
: 'border-red-200 bg-red-50 cursor-not-allowed'
}`}
onClick={() => handleSlotClick(slot)}
>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<div className='flex items-center gap-2 text-sm font-medium'>
<Clock className='h-4 w-4' />
{slot.time} -{' '}
{String(parseInt(slot.time.split(':')[0]) + 1).padStart(2, '0')}
:00
</div>
<div className='flex items-center gap-2 text-sm text-gray-600'>
<MapPin className='h-4 w-4' />
{slot.courtName}
</div>
{!slot.available && (
<div className='text-xs text-red-600'>Already booked</div>
)}
</div>
<Button
size='sm'
disabled={!slot.available}
className={slot.available ? 'bg-green-600 hover:bg-green-700' : ''}
>
{slot.available ? 'Book' : 'Booked'}
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* No Slots Message */}
{!loading && courts.length > 0 && bookingSlots.length === 0 && (
<div className='text-center py-8'>
<p className='text-gray-500'>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-blue-50 p-4 rounded-lg space-y-2'>
<div className='flex items-center gap-2 text-sm'>
<Calendar className='h-4 w-4' />
{selectedDate.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
})}
</div>
<div className='flex items-center gap-2 text-sm'>
<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'>
<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-gray-400' />
<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-gray-500'>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>
);
}
@@ -0,0 +1,429 @@
'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 { toast } = useToast();
useEffect(() => {
fetchBookings();
}, []);
const fetchBookings = async () => {
try {
setLoading(true);
const response = await fetch('/api/bookings');
if (response.ok) {
const data = await response.json();
// Filter to show only future and today's bookings
const now = new Date();
const today = now.toISOString().split('T')[0];
const relevantBookings = data.bookings.filter((booking: Booking) => {
if (booking.status !== 'active') return false;
return booking.date >= today;
});
setBookings(relevantBookings);
}
} catch (error) {
console.error('Error fetching bookings:', error);
toast({
title: 'Error',
description: 'Failed to fetch your bookings',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
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) => {
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);
// Allow modifications if booking is more than 2 hours away
return hoursDiff > 2;
};
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-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 flex flex-row items-center justify-between'>
<CardTitle className='text-base flex items-center gap-2'>
<Calendar className='h-4 w-4' />
Your 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-gray-500 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-blue-600' />
<span className='font-medium text-sm'>{booking.court.name}</span>
{isToday(booking.date) && (
<Badge variant='secondary' className='text-xs'>
Today
</Badge>
)}
</div>
<div className='flex items-center gap-4 text-xs text-gray-500'>
<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-gray-600'>
<User className='h-3 w-3' />
<span>Playing with: {partner}</span>
</div>
)}
{additionalNotes && (
<p className='text-xs text-gray-600 italic bg-gray-50 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-red-600 hover:text-red-700'
>
<Trash2 className='h-3 w-3' />
</Button>
</div>
</div>
{!canModify && (
<p className='text-xs text-amber-600 bg-amber-50 p-2 rounded'>
Booking can only be modified more than 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-blue-50 p-4 rounded-lg space-y-2'>
<div className='flex items-center gap-2 text-sm'>
<Calendar className='h-4 w-4' />
{formatDate(selectedBooking.date)}
</div>
<div className='flex items-center gap-2 text-sm'>
<Clock className='h-4 w-4' />
{selectedBooking.startTime} - {selectedBooking.endTime}
</div>
<div className='flex items-center gap-2 text-sm'>
<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-gray-400' />
<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-gray-50 rounded'>
<p className='text-sm font-medium'>
{selectedBooking.court.name} - {formatDate(selectedBooking.date)}
</p>
<p className='text-sm text-gray-600'>
{selectedBooking.startTime} - {selectedBooking.endTime}
</p>
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm} className='bg-red-600 hover:bg-red-700'>
Cancel Booking
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+191
View File
@@ -0,0 +1,191 @@
'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';
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 formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', {
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
alert('Booking created successfully!');
} else {
alert(result.error || 'Booking failed');
}
} catch (error) {
console.error('Booking error:', error);
alert('An error occurred while creating the booking');
}
};
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'
>
{time}
{isBooked && (
<Badge
variant='destructive'
className='absolute -top-1 -right-1 h-2 w-2 p-0'
/>
)}
</Button>
);
})}
</div>
</div>
{/* Booking Summary */}
{selectedDate && selectedSlot && selectedCourt && (
<div className='bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2'>
<h4 className='font-medium text-blue-900'>Booking Summary</h4>
<div className='text-sm text-blue-700 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>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Bell, LogOut, Settings, User, Calendar } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface DashboardHeaderProps {
user: {
userId: string;
email: string;
role: 'user' | 'admin';
};
}
export function DashboardHeader({ user }: DashboardHeaderProps) {
const router = useRouter();
const { toast } = useToast();
const [isLoggingOut, setIsLoggingOut] = useState(false);
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-white/80 backdrop-blur-md border-b border-gray-200 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-blue-600' />
<h1 className='text-xl font-bold text-gray-900'>TT Booking</h1>
</div>
{user.role === 'admin' && (
<Badge variant='secondary' className='bg-purple-100 text-purple-800'>
Admin
</Badge>
)}
</div>
<div className='flex items-center space-x-4'>
<Button variant='ghost' size='sm'>
<Bell className='h-4 w-4' />
</Button>
{user.role === 'admin' && (
<Button variant='ghost' size='sm' onClick={() => router.push('/admin')}>
<Settings className='h-4 w-4 mr-2' />
Admin Panel
</Button>
)}
<div className='flex items-center space-x-2'>
<User className='h-4 w-4 text-gray-600' />
<span className='text-sm text-gray-700'>{user.email.split('@')[0]}</span>
</div>
<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>
</header>
);
}
+135
View File
@@ -0,0 +1,135 @@
'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>
);
}
+135
View File
@@ -0,0 +1,135 @@
'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>
);
}
+126
View File
@@ -0,0 +1,126 @@
'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-gray-500 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 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-blue-600' />
<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-gray-500'>
<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-gray-600 italic'>{booking.notes}</p>}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+134
View File
@@ -0,0 +1,134 @@
'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';
interface DashboardHeaderProps {
user: {
userId: string;
email: string;
role: 'user' | 'admin';
};
}
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);
// 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-white/80 backdrop-blur-md border-b border-gray-200 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-blue-600' />
<h1 className='text-xl font-bold text-gray-900'>TT Booking</h1>
</div>
{user.role === 'admin' && (
<Badge variant='secondary' className='bg-purple-100 text-purple-800'>
Admin
</Badge>
)}
</div>
<div className='flex items-center space-x-4'>
<NotificationBell unreadCount={unreadCount} onClick={() => setShowAnnouncements(true)} />
{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-gray-600' />
<span className='text-sm text-gray-700'>{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>
);
}
+179
View File
@@ -0,0 +1,179 @@
'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-red-500' />;
case 'medium':
return <AlertTriangle className='h-4 w-4 text-yellow-500' />;
default:
return <Info className='h-4 w-4 text-blue-500' />;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'border-red-200 bg-red-50';
case 'medium':
return 'border-yellow-200 bg-yellow-50';
default:
return 'border-blue-200 bg-blue-50';
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', {
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-gray-900'></div>
<p className='ml-2'>Loading announcements...</p>
</div>
) : announcements.length === 0 ? (
<div className='text-center py-8 text-gray-500'>
<Bell className='h-12 w-12 mx-auto mb-4 text-gray-300' />
<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-gray-700 mb-2'>{announcement.content}</p>
<p className='text-xs text-gray-500'>{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>
);
}
+9
View File
@@ -0,0 +1,9 @@
'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>;
}
+106
View File
@@ -0,0 +1,106 @@
'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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,47 @@
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 };
+213
View File
@@ -0,0 +1,213 @@
"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
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("default", { 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()}
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
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,122 @@
"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,
}
+178
View File
@@ -0,0 +1,178 @@
"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
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,26 @@
"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 }
+159
View File
@@ -0,0 +1,159 @@
"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
@@ -0,0 +1,29 @@
"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
@@ -0,0 +1,120 @@
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
@@ -0,0 +1,55 @@
'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
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,113 @@
'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
@@ -0,0 +1,26 @@
'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
@@ -0,0 +1,187 @@
'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 };
+284
View File
@@ -0,0 +1,284 @@
'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 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface User {
id: string;
email: string;
name: string;
surname: string;
role: string;
createdAt: string;
}
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 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,
});
} 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-gray-900'></div>
<p className='ml-2'>Loading profile...</p>
</div>
</CardContent>
</Card>
);
}
if (!user) {
return (
<Card>
<CardContent className='p-6'>
<div className='text-center text-gray-500'>
<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-gray-50 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-gray-50 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-gray-100 rounded text-gray-600'>
<Mail className='h-4 w-4' />
<span>{user.email}</span>
<span className='text-xs text-gray-500 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-gray-50 rounded'>
<Calendar className='h-4 w-4' />
<span>
{new Date(user.createdAt).toLocaleDateString('en-US', {
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 rounded text-blue-800 capitalize font-medium'>
{user.role}
</div>
</div>
<div className='space-y-2'>
<Label>User ID</Label>
<div className='p-2 bg-gray-50 rounded text-gray-600 font-mono text-sm'>{user.id}</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,10 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './lib/db/schema.ts',
out: './lib/db/migrations',
driver: 'better-sqlite',
dbCredentials: {
url: './sqlite.db',
},
} satisfies Config;
+189
View File
@@ -0,0 +1,189 @@
'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 };
+95
View File
@@ -0,0 +1,95 @@
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',
// 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',
SYSTEM: 'system',
} as const;
+48
View File
@@ -0,0 +1,48 @@
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;
}
+15
View File
@@ -0,0 +1,15 @@
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';
const sqlite = new Database('./sqlite.db');
export const db = drizzle(sqlite, { schema });
// Run migrations on startup
try {
migrate(db, { migrationsFolder: './lib/db/migrations' });
console.log('Database migrations completed');
} catch (error) {
console.error('Database migration failed:', error);
}
+1
View File
@@ -0,0 +1 @@
{"version":"5","dialect":"sqlite","entries":[]}
+124
View File
@@ -0,0 +1,124 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';
// Users table
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
surname: text('surname').notNull(),
password: text('password').notNull(),
role: text('role', { enum: ['user', 'admin'] })
.notNull()
.default('user'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Courts table
export const courts = sqliteTable('courts', {
id: text('id').primaryKey(),
name: text('name').notNull(),
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Settings table for admin configuration
export const settings = sqliteTable('settings', {
id: text('id').primaryKey(),
key: text('key').notNull().unique(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Time slots configuration
export const timeSlots = sqliteTable('time_slots', {
id: text('id').primaryKey(),
dayOfWeek: integer('day_of_week').notNull(), // 0 = Sunday, 1 = Monday, etc.
startTime: text('start_time').notNull(), // Format: "HH:MM"
endTime: text('end_time').notNull(), // Format: "HH:MM"
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Bookings table
export const bookings = sqliteTable('bookings', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
courtId: text('court_id')
.notNull()
.references(() => courts.id, { onDelete: 'cascade' }),
date: text('date').notNull(), // Format: "YYYY-MM-DD"
startTime: text('start_time').notNull(), // Format: "HH:MM"
endTime: text('end_time').notNull(), // Format: "HH:MM"
status: text('status', { enum: ['active', 'cancelled'] })
.notNull()
.default('active'),
notes: text('notes'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Announcements table
export const announcements = sqliteTable('announcements', {
id: text('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
priority: text('priority', { enum: ['low', 'medium', 'high'] })
.notNull()
.default('medium'),
expiresAt: integer('expires_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Activity logs for admin transparency
export const activityLogs = sqliteTable('activity_logs', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
action: text('action').notNull(),
entityType: text('entity_type').notNull(),
entityId: text('entity_id'),
details: text('details'), // JSON string
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
// Zod schemas for validation
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);
export const insertCourtSchema = createInsertSchema(courts);
export const selectCourtSchema = createSelectSchema(courts);
export const insertBookingSchema = createInsertSchema(bookings);
export const selectBookingSchema = createSelectSchema(bookings);
export const insertAnnouncementSchema = createInsertSchema(announcements);
export const selectAnnouncementSchema = createSelectSchema(announcements);
export const insertTimeSlotSchema = createInsertSchema(timeSlots);
export const selectTimeSlotSchema = createSelectSchema(timeSlots);
export const insertSettingSchema = createInsertSchema(settings);
export const selectSettingSchema = createSelectSchema(settings);
export const insertActivityLogSchema = createInsertSchema(activityLogs);
export const selectActivityLogSchema = createSelectSchema(activityLogs);
// Types
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Court = typeof courts.$inferSelect;
export type NewCourt = typeof courts.$inferInsert;
export type Booking = typeof bookings.$inferSelect;
export type NewBooking = typeof bookings.$inferInsert;
export type Announcement = typeof announcements.$inferSelect;
export type NewAnnouncement = typeof announcements.$inferInsert;
export type TimeSlot = typeof timeSlots.$inferSelect;
export type NewTimeSlot = typeof timeSlots.$inferInsert;
export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert;
export type ActivityLog = typeof activityLogs.$inferSelect;
export type NewActivityLog = typeof activityLogs.$inferInsert;
+107
View File
@@ -0,0 +1,107 @@
import nodemailer from 'nodemailer';
interface EmailOptions {
to: string;
subject: string;
html: string;
}
// Create reusable transporter object using SMTP transport
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD, // Use App Password for Gmail
},
});
export async function sendEmail({ to, subject, html }: EmailOptions) {
try {
const info = await transporter.sendMail({
from: `"Table Tennis Booking" <${process.env.EMAIL_USER}>`,
to,
subject,
html,
});
console.log('Email sent: %s', info.messageId);
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('Error sending email:', error);
return { success: false, error };
}
}
export function generateBookingConfirmationEmail(booking: {
id: string;
date: string;
startTime: string;
endTime: string;
courtName: string;
userName: string;
}) {
return {
subject: 'Booking Confirmation - Table Tennis Court',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Booking Confirmed!</h2>
<p>Hello ${booking.userName},</p>
<p>Your table tennis court booking has been confirmed:</p>
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Booking Details</h3>
<p><strong>Booking ID:</strong> ${booking.id}</p>
<p><strong>Date:</strong> ${booking.date}</p>
<p><strong>Time:</strong> ${booking.startTime} - ${booking.endTime}</p>
<p><strong>Court:</strong> ${booking.courtName}</p>
</div>
<p>Please arrive 5 minutes before your booking time. If you need to cancel or modify your booking, please log in to your account.</p>
<p>Thank you for choosing our table tennis facility!</p>
<hr style="margin: 30px 0;">
<p style="font-size: 12px; color: #666;">
This is an automated email. Please do not reply to this message.
</p>
</div>
`,
};
}
export function generateBookingCancellationEmail(booking: {
id: string;
date: string;
startTime: string;
endTime: string;
courtName: string;
userName: string;
}) {
return {
subject: 'Booking Cancelled - Table Tennis Court',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #d32f2f;">Booking Cancelled</h2>
<p>Hello ${booking.userName},</p>
<p>Your table tennis court booking has been cancelled:</p>
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Cancelled Booking Details</h3>
<p><strong>Booking ID:</strong> ${booking.id}</p>
<p><strong>Date:</strong> ${booking.date}</p>
<p><strong>Time:</strong> ${booking.startTime} - ${booking.endTime}</p>
<p><strong>Court:</strong> ${booking.courtName}</p>
</div>
<p>You can make a new booking anytime through our booking system.</p>
<p>Thank you for using our table tennis facility!</p>
<hr style="margin: 30px 0;">
<p style="font-size: 12px; color: #666;">
This is an automated email. Please do not reply to this message.
</p>
</div>
`,
};
}
+101
View File
@@ -0,0 +1,101 @@
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
const secretKey = process.env.NEXTAUTH_SECRET;
const encodedKey = new TextEncoder().encode(secretKey);
export interface SessionPayload {
userId: string;
email: string;
role: 'user' | 'admin';
expiresAt: Date;
}
export async function encrypt(payload: SessionPayload) {
return new SignJWT({
userId: payload.userId,
email: payload.email,
role: payload.role,
expiresAt: payload.expiresAt.getTime(),
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey);
}
export async function decrypt(session: string | undefined = '') {
try {
const { payload } = await jwtVerify(session, encodedKey, {
algorithms: ['HS256'],
});
return {
userId: payload.userId as string,
email: payload.email as string,
role: payload.role as 'user' | 'admin',
expiresAt: new Date(payload.expiresAt as number),
};
} catch (error) {
console.log('Failed to verify session');
return null;
}
}
export async function createSession(payload: Omit<SessionPayload, 'expiresAt'>) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const session = await encrypt({ ...payload, expiresAt });
const cookieStore = await cookies();
cookieStore.set('session', session, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
});
}
export async function updateSession() {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
const payload = await decrypt(session);
if (!session || !payload) {
return null;
}
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const newSession = await encrypt({ ...payload, expiresAt: expires });
cookieStore.set('session', newSession, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expires,
sameSite: 'lax',
path: '/',
});
}
export async function deleteSession() {
const cookieStore = await cookies();
cookieStore.delete('session');
}
export async function getSession() {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
return await decrypt(session);
}
export async function verifySession() {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
const payload = await decrypt(session);
if (!payload) {
return { isAuth: false, userId: null, role: null };
}
return { isAuth: true, userId: payload.userId, role: payload.role };
}
+62
View File
@@ -0,0 +1,62 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
export function formatTime(time: string): string {
const [hours, minutes] = time.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour % 12 || 12;
return `${displayHour}:${minutes} ${ampm}`;
}
export function formatDate(date: string): string {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export function isWithinBookingWindow(date: string): boolean {
const bookingDate = new Date(date);
const today = new Date();
const maxDate = new Date();
maxDate.setDate(today.getDate() + 6); // 7 days including today
// Reset time to start of day for comparison
today.setHours(0, 0, 0, 0);
maxDate.setHours(23, 59, 59, 999);
bookingDate.setHours(0, 0, 0, 0);
return bookingDate >= today && bookingDate <= maxDate;
}
export function getWeekDays(): Array<{ value: number; label: string }> {
return [
{ value: 0, label: 'Sunday' },
{ value: 1, label: 'Monday' },
{ value: 2, label: 'Tuesday' },
{ value: 3, label: 'Wednesday' },
{ value: 4, label: 'Thursday' },
{ value: 5, label: 'Friday' },
{ value: 6, label: 'Saturday' },
];
}
export function generateTimeSlots(startHour: number, endHour: number): string[] {
const slots = [];
for (let hour = startHour; hour < endHour; hour++) {
const hourStr = hour < 10 ? '0' + hour : hour.toString();
slots.push(`${hourStr}:00`);
}
return slots;
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { decrypt } from '@/lib/session';
// Protected routes that require authentication
const protectedRoutes = ['/dashboard', '/admin', '/bookings', '/profile'];
// Admin routes that require admin role
const adminRoutes = ['/admin'];
// Auth routes that should redirect if already authenticated
const authRoutes = ['/login', '/register'];
export default async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
const isProtectedRoute = protectedRoutes.some((route) => path.startsWith(route));
const isAdminRoute = adminRoutes.some((route) => path.startsWith(route));
const isAuthRoute = authRoutes.includes(path);
const cookie = req.cookies.get('session')?.value;
const session = await decrypt(cookie);
// Redirect to login if accessing protected route without session
if (isProtectedRoute && !session?.userId) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
// Redirect to dashboard if accessing auth routes while authenticated
if (isAuthRoute && session?.userId) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl));
}
// Redirect to dashboard if accessing admin route without admin role
if (isAdminRoute && session?.role !== 'admin') {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
};
module.exports = nextConfig;
+78
View File
@@ -0,0 +1,78 @@
events {
worker_connections 1024;
}
http {
upstream app {
server tt-booking:3000;
}
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;" always;
server {
listen 80;
server_name your-domain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL configuration
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# API rate limiting
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Login rate limiting
location /api/auth/login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static files and general requests
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
}
+12070
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
{
"name": "tt-booking",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:push": "drizzle-kit push:sqlite",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:setup": "tsx scripts/setup-db.ts",
"postinstall": "npm run db:push"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.0.7",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^9.2.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"drizzle-orm": "^0.29.1",
"drizzle-zod": "^0.5.1",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.544.0",
"next": "^15.5.3",
"next-themes": "^0.2.1",
"nodemailer": "^6.9.7",
"react": "^19.1.1",
"react-day-picker": "^9.10.0",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20",
"@types/nodemailer": "^6.4.14",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"drizzle-kit": "^0.20.6",
"eslint": "^8",
"eslint-config-next": "^15.5.3",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"tsx": "^4.20.5",
"typescript": "^5"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+176
View File
@@ -0,0 +1,176 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
async function setupDatabase() {
const db = new Database('sqlite.db');
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
surname TEXT NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS courts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
description TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS time_slots (
id TEXT PRIMARY KEY,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
court_id TEXT NOT NULL,
date TEXT NOT NULL,
time_slot_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'cancelled', 'pending')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (court_id) REFERENCES courts(id),
FOREIGN KEY (time_slot_id) REFERENCES time_slots(id)
);
CREATE TABLE IF NOT EXISTS announcements (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS activity_logs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
details TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
console.log('Tables created successfully!');
// Insert default admin user
const now = Date.now();
const adminPassword = await bcrypt.hash('admin123', 10);
try {
const stmt = db.prepare(`
INSERT OR IGNORE INTO users (id, email, name, surname, password, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
'admin-' + crypto.randomUUID(),
'admin@ttbooking.com',
'Admin',
'User',
adminPassword,
'admin',
now,
now
);
console.log('Admin user created: admin@ttbooking.com / admin123');
} catch (error) {
console.log('Admin user might already exist');
}
// Insert default courts
try {
const courtStmt = db.prepare(`
INSERT OR IGNORE INTO courts (id, name, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`);
courtStmt.run('court-1', 'Court 1', 1, now, now);
courtStmt.run('court-2', 'Court 2', 1, now, now);
console.log('Default courts created');
} catch (error) {
console.log('Courts might already exist');
}
// Insert time slots
try {
const timeSlotStmt = db.prepare(`
INSERT OR IGNORE INTO time_slots (id, start_time, end_time, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
const timeSlots = [
['09:00', '10:00'],
['10:00', '11:00'],
['11:00', '12:00'],
['12:00', '13:00'],
['13:00', '14:00'],
['14:00', '15:00'],
['15:00', '16:00'],
['16:00', '17:00'],
['17:00', '18:00'],
['18:00', '19:00'],
['19:00', '20:00'],
['20:00', '21:00'],
];
timeSlots.forEach(([start, end], index) => {
timeSlotStmt.run(`slot-${index + 1}`, start, end, 1, now, now);
});
console.log('Time slots created');
} catch (error) {
console.log('Time slots might already exist');
}
// Insert default settings
try {
const settingsStmt = db.prepare(`
INSERT OR IGNORE INTO settings (id, key, value, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
settingsStmt.run('setting-1', 'booking_advance_days', '7', 'Days in advance users can book', now, now);
settingsStmt.run('setting-2', 'max_bookings_per_user', '3', 'Maximum bookings per user per week', now, now);
settingsStmt.run('setting-3', 'booking_duration', '60', 'Booking duration in minutes', now, now);
console.log('Default settings created');
} catch (error) {
console.log('Settings might already exist');
}
db.close();
console.log('Database setup completed!');
}
setupDatabase().catch(console.error);
+263
View File
@@ -0,0 +1,263 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from '../lib/db/schema';
import { sql } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import bcrypt from 'bcryptjs';
const sqlite = new Database('./sqlite.db');
const db = drizzle(sqlite, { schema });
async function resetDatabase() {
console.log('Resetting database...');
// Drop all tables
const tables = [
'activity_logs',
'bookings',
'announcements',
'time_slots',
'settings',
'courts',
'users',
'__drizzle_migrations',
'__old_push_courts',
'__old_push_users',
];
for (const table of tables) {
try {
await db.run(sql.raw(`DROP TABLE IF EXISTS ${table}`));
console.log(`Dropped table: ${table}`);
} catch (error) {
console.log(`Table ${table} doesn't exist or error dropping:`, error);
}
}
// Create all tables with current schema
console.log('Creating tables...');
// Users table
await db.run(sql`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
surname TEXT NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Courts table
await db.run(sql`
CREATE TABLE courts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Settings table
await db.run(sql`
CREATE TABLE settings (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Time slots table
await db.run(sql`
CREATE TABLE time_slots (
id TEXT PRIMARY KEY,
day_of_week INTEGER NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Bookings table
await db.run(sql`
CREATE TABLE bookings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
court_id TEXT NOT NULL REFERENCES courts(id) ON DELETE CASCADE,
date TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'cancelled')),
notes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Announcements table with all required columns
await db.run(sql`
CREATE TABLE announcements (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Activity logs table
await db.run(sql`
CREATE TABLE activity_logs (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
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
)
`);
console.log('All tables created successfully!');
// Insert seed data
console.log('Inserting seed data...');
const now = Date.now();
// Create admin user
const hashedPassword = await bcrypt.hash('admin123', 12);
await db.insert(schema.users).values({
id: randomUUID(),
email: 'admin@ttbooking.com',
name: 'Admin',
surname: 'User',
password: hashedPassword,
role: 'admin',
createdAt: new Date(now),
updatedAt: new Date(now),
});
// Create test user
const testPassword = await bcrypt.hash('password123', 12);
await db.insert(schema.users).values({
id: randomUUID(),
email: 'user@test.com',
name: 'Test',
surname: 'User',
password: testPassword,
role: 'user',
createdAt: new Date(now),
updatedAt: new Date(now),
});
// Create courts
const court1Id = randomUUID();
const court2Id = randomUUID();
await db.insert(schema.courts).values([
{
id: court1Id,
name: 'Court 1',
isActive: true,
createdAt: new Date(now),
updatedAt: new Date(now),
},
{
id: court2Id,
name: 'Court 2',
isActive: true,
createdAt: new Date(now),
updatedAt: new Date(now),
},
]);
// Insert default settings
await db.insert(schema.settings).values([
{
id: randomUUID(),
key: 'booking_window_days',
value: '7',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'max_booking_duration_hours',
value: '2',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'min_booking_duration_minutes',
value: '30',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'booking_start_time',
value: '08:00',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'booking_end_time',
value: '22:00',
updatedAt: new Date(now),
},
{
id: randomUUID(),
key: 'allow_weekend_bookings',
value: 'true',
updatedAt: new Date(now),
},
]);
// Create time slots for all days (8 AM to 10 PM)
const timeSlotData = [];
for (let day = 0; day < 7; day++) {
for (let hour = 8; hour < 22; hour += 2) {
timeSlotData.push({
id: randomUUID(),
dayOfWeek: day,
startTime: `${hour.toString().padStart(2, '0')}:00`,
endTime: `${(hour + 2).toString().padStart(2, '0')}:00`,
isActive: true,
createdAt: new Date(now),
updatedAt: new Date(now),
});
}
}
await db.insert(schema.timeSlots).values(timeSlotData);
// Create sample announcement
await db.insert(schema.announcements).values({
id: randomUUID(),
title: 'Welcome to Table Tennis Booking System',
content: 'Book your court times easily and manage your games efficiently.',
isActive: true,
priority: 'high',
expiresAt: null,
createdAt: new Date(now),
updatedAt: new Date(now),
});
console.log('Seed data inserted successfully!');
console.log('Database reset complete!');
sqlite.close();
}
resetDatabase().catch(console.error);
+50
View File
@@ -0,0 +1,50 @@
import { db } from '@/lib/db';
import { announcements } from '@/lib/db/schema';
import { randomUUID } from 'crypto';
async function seedAnnouncements() {
try {
const testAnnouncements = [
{
id: randomUUID(),
title: 'Welcome to the New Booking System!',
content:
'We have upgraded our table tennis booking system with new features including mobile support, partner booking, and booking management. Enjoy your games!',
priority: 'high' as const,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: randomUUID(),
title: 'Court Maintenance Schedule',
content:
'Court 2 will be under maintenance this Friday from 2 PM to 4 PM. Please plan your bookings accordingly.',
priority: 'medium' as const,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: randomUUID(),
title: 'New Partnership Feature',
content:
'You can now specify your playing partner when making a booking. This helps other players know who will be using the court.',
priority: 'low' as const,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
for (const announcement of testAnnouncements) {
await db.insert(announcements).values(announcement);
}
console.log('Test announcements created successfully!');
} catch (error) {
console.error('Error creating test announcements:', error);
}
}
seedAnnouncements();
+203
View File
@@ -0,0 +1,203 @@
import { db } from '../lib/db';
import { users, courts as courtsTable, bookings, announcements, activityLogs } from '../lib/db/schema';
import { randomUUID } from 'crypto';
import bcrypt from 'bcryptjs';
async function seedData() {
try {
console.log('Starting data seeding...');
// Get existing users to add sample bookings and activities
const existingUsers = await db.select().from(users);
if (existingUsers.length < 2) {
console.log('Not enough users found. Please run the reset-database script first.');
return;
}
const adminUser = existingUsers.find((u) => u.role === 'admin');
const regularUser = existingUsers.find((u) => u.role === 'user');
const courts = await db.select().from(courtsTable);
if (!adminUser || !regularUser || courts.length === 0) {
console.log('Missing admin user, regular user, or courts. Please run reset-database first.');
return;
}
const now = new Date();
const today = now.toISOString().split('T')[0];
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Add some sample bookings
console.log('Creating sample bookings...');
const sampleBookings = [
{
id: randomUUID(),
userId: regularUser.id,
courtId: courts[0].id,
date: today,
startTime: '19:00',
endTime: '20:00',
status: 'active' as const,
notes: 'Regular evening practice session',
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
updatedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
},
{
id: randomUUID(),
userId: regularUser.id,
courtId: courts[1] ? courts[1].id : courts[0].id,
date: tomorrow,
startTime: '20:00',
endTime: '21:00',
status: 'active' as const,
notes: 'Tournament preparation',
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
updatedAt: new Date(now.getTime() - 1 * 60 * 60 * 1000),
},
];
await db.insert(bookings).values(sampleBookings);
// Add sample activity logs
console.log('Creating sample activity logs...');
const sampleLogs = [
{
id: randomUUID(),
userId: adminUser.id,
action: 'login',
entityType: 'user',
entityId: adminUser.id,
details: JSON.stringify({
email: adminUser.email,
role: adminUser.role,
loginMethod: 'password',
}),
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
createdAt: new Date(now.getTime() - 3 * 60 * 60 * 1000), // 3 hours ago
},
{
id: randomUUID(),
userId: regularUser.id,
action: 'create_booking',
entityType: 'booking',
entityId: sampleBookings[0].id,
details: JSON.stringify({
courtId: courts[0].id,
courtName: courts[0].name,
date: today,
startTime: '19:00',
endTime: '20:00',
}),
ipAddress: '192.168.1.101',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
},
{
id: randomUUID(),
userId: regularUser.id,
action: 'login',
entityType: 'user',
entityId: regularUser.id,
details: JSON.stringify({
email: regularUser.email,
role: regularUser.role,
loginMethod: 'password',
}),
ipAddress: '192.168.1.101',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
createdAt: new Date(now.getTime() - 2.5 * 60 * 60 * 1000), // 2.5 hours ago
},
{
id: randomUUID(),
userId: adminUser.id,
action: 'create_announcement',
entityType: 'announcement',
entityId: null,
details: JSON.stringify({
title: 'System Maintenance',
priority: 'high',
action: 'created_via_seed',
}),
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
},
{
id: randomUUID(),
userId: regularUser.id,
action: 'create_booking',
entityType: 'booking',
entityId: sampleBookings[1].id,
details: JSON.stringify({
courtId: courts[1] ? courts[1].id : courts[0].id,
courtName: courts[1] ? courts[1].name : courts[0].name,
date: tomorrow,
startTime: '20:00',
endTime: '21:00',
}),
ipAddress: '192.168.1.101',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
},
{
id: randomUUID(),
userId: adminUser.id,
action: 'update_settings',
entityType: 'settings',
entityId: null,
details: JSON.stringify({
changedSettings: ['booking_window_days', 'max_booking_duration_hours'],
previousValues: { booking_window_days: '7', max_booking_duration_hours: '2' },
newValues: { booking_window_days: '14', max_booking_duration_hours: '3' },
}),
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
createdAt: new Date(now.getTime() - 30 * 60 * 1000), // 30 minutes ago
},
];
await db.insert(activityLogs).values(sampleLogs);
// Add more announcements for testing
console.log('Creating additional announcements...');
const additionalAnnouncements = [
{
id: randomUUID(),
title: 'New Court Rules',
content: 'Please remember to clean up after your sessions and respect the time limits.',
isActive: true,
priority: 'medium' as const,
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), // 1 week from now
createdAt: new Date(now.getTime() - 4 * 60 * 60 * 1000), // 4 hours ago
updatedAt: new Date(now.getTime() - 4 * 60 * 60 * 1000),
},
{
id: randomUUID(),
title: 'Tournament Sign-ups Open',
content: 'The annual table tennis tournament sign-ups are now open! Register by the end of this month.',
isActive: true,
priority: 'high' as const,
expiresAt: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
createdAt: new Date(now.getTime() - 24 * 60 * 60 * 1000), // 1 day ago
updatedAt: new Date(now.getTime() - 24 * 60 * 60 * 1000),
},
];
await db.insert(announcements).values(additionalAnnouncements);
console.log('Sample data seeding completed successfully!');
console.log(`Created:
- ${sampleBookings.length} sample bookings
- ${sampleLogs.length} activity logs
- ${additionalAnnouncements.length} additional announcements`);
} catch (error) {
console.error('Error seeding data:', error);
}
}
seedData();
+133
View File
@@ -0,0 +1,133 @@
import { db } from '@/lib/db';
import { users, courts, timeSlots, settings } from '@/lib/db/schema';
import { hashPassword } from '@/lib/auth';
import { generateId } from '@/lib/utils';
async function setupDatabase() {
try {
console.log('Setting up database...');
// Create admin user
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
const hashedAdminPassword = await hashPassword(adminPassword);
const now = new Date();
await db
.insert(users)
.values({
id: generateId(),
email: adminEmail,
name: 'Admin',
surname: 'User',
password: hashedAdminPassword,
role: 'admin',
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing();
// Create default courts
await db
.insert(courts)
.values([
{
id: generateId(),
name: 'Court 1',
isActive: true,
createdAt: now,
updatedAt: now,
},
{
id: generateId(),
name: 'Court 2',
isActive: true,
createdAt: now,
updatedAt: now,
},
])
.onConflictDoNothing();
// Create default time slots
// Monday (1) and Tuesday (2): 19:00-23:00
const mondayTuesdaySlots = [];
for (let day of [1, 2]) {
for (let hour = 19; hour < 23; hour++) {
const hourStr = hour < 10 ? '0' + hour : hour.toString();
const nextHourStr = hour + 1 < 10 ? '0' + (hour + 1) : (hour + 1).toString();
mondayTuesdaySlots.push({
id: generateId(),
dayOfWeek: day,
startTime: `${hourStr}:00`,
endTime: `${nextHourStr}:00`,
isActive: true,
createdAt: now,
updatedAt: now,
});
}
}
// Sunday (0): 12:00-17:00
const sundaySlots = [];
for (let hour = 12; hour < 17; hour++) {
const hourStr = hour < 10 ? '0' + hour : hour.toString();
const nextHourStr = hour + 1 < 10 ? '0' + (hour + 1) : (hour + 1).toString();
sundaySlots.push({
id: generateId(),
dayOfWeek: 0,
startTime: `${hourStr}:00`,
endTime: `${nextHourStr}:00`,
isActive: true,
createdAt: now,
updatedAt: now,
});
}
await db
.insert(timeSlots)
.values([...mondayTuesdaySlots, ...sundaySlots])
.onConflictDoNothing();
// Create default settings
await db
.insert(settings)
.values([
{
id: generateId(),
key: 'booking_window_days',
value: '7',
updatedAt: now,
},
{
id: generateId(),
key: 'max_bookings_per_user',
value: '3',
updatedAt: now,
},
{
id: generateId(),
key: 'booking_cancellation_hours',
value: '2',
updatedAt: now,
},
])
.onConflictDoNothing();
console.log('Database setup completed successfully!');
console.log(`Admin user created: ${adminEmail}`);
console.log(`Admin password: ${adminPassword}`);
} catch (error) {
console.error('Database setup error:', error);
throw error;
}
}
// Run setup if this file is executed directly
if (require.main === module) {
setupDatabase()
.then(() => process.exit(0))
.catch(() => process.exit(1));
}
export { setupDatabase };
+71
View File
@@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["./components/*"],
"@/lib/*": ["./lib/*"],
"@/app/*": ["./app/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

Some files were not shown because too many files have changed in this diff Show More