initial version of the app
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user