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
@@ -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>
);
}