theming, date, time localisation, additional features, seeding initial cleanup
This commit is contained in:
@@ -236,13 +236,13 @@ export function AdminAnnouncementManagement() {
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'text-red-600 bg-red-50';
|
||||
return 'text-destructive bg-destructive/10 dark:bg-destructive/20';
|
||||
case 'medium':
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
return 'text-amber-600 bg-amber-50 dark:text-amber-400 dark:bg-amber-950/50';
|
||||
case 'low':
|
||||
return 'text-green-600 bg-green-50';
|
||||
return 'text-green-600 bg-green-50 dark:text-green-400 dark:bg-green-950/50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
return 'text-muted-foreground bg-muted';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -387,8 +387,8 @@ export function AdminAnnouncementManagement() {
|
||||
<TableRow key={announcement.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className='font-medium'>{announcement.title}</div>
|
||||
<div className='text-sm text-gray-500 truncate max-w-xs'>
|
||||
<div className='font-medium text-foreground'>{announcement.title}</div>
|
||||
<div className='text-sm text-muted-foreground truncate max-w-xs'>
|
||||
{announcement.content}
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,16 +420,16 @@ export function AdminAnnouncementManagement() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4 text-gray-500' />
|
||||
<Calendar className='h-4 w-4 text-muted-foreground' />
|
||||
{announcement.expiresAt
|
||||
? new Date(announcement.expiresAt).toLocaleDateString()
|
||||
? new Date(announcement.expiresAt).toLocaleDateString('en-IE')
|
||||
: 'Never'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4 text-gray-500' />
|
||||
{new Date(announcement.createdAt).toLocaleDateString()}
|
||||
<Calendar className='h-4 w-4 text-muted-foreground' />
|
||||
{new Date(announcement.createdAt).toLocaleDateString('en-IE')}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -445,7 +445,7 @@ export function AdminAnnouncementManagement() {
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleDeleteAnnouncement(announcement.id)}
|
||||
className='text-red-600 hover:text-red-700'
|
||||
className='text-destructive hover:text-destructive/90'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
@@ -456,7 +456,7 @@ export function AdminAnnouncementManagement() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
{announcements.length === 0 && (
|
||||
<div className='text-center py-8 text-gray-500'>
|
||||
<div className='text-center py-8 text-muted-foreground'>
|
||||
No announcements found. Create your first announcement!
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -297,7 +297,7 @@ export function AdminCourtManagement() {
|
||||
<div>
|
||||
<h3 className='font-medium'>{court.name}</h3>
|
||||
<p className='text-sm text-gray-500'>
|
||||
Created {new Date(court.createdAt).toLocaleDateString()}
|
||||
Created {new Date(court.createdAt).toLocaleDateString('en-IE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,8 @@ interface SettingsData {
|
||||
booking_end_time: string;
|
||||
allow_weekend_bookings: string;
|
||||
max_bookings_per_user_per_hour_per_day: string;
|
||||
allow_booking_modifications: string;
|
||||
booking_modification_hours_before: string;
|
||||
}
|
||||
|
||||
export function AdminSettingsManagement() {
|
||||
@@ -35,6 +37,8 @@ export function AdminSettingsManagement() {
|
||||
booking_end_time: '22:00',
|
||||
allow_weekend_bookings: 'true',
|
||||
max_bookings_per_user_per_hour_per_day: '1',
|
||||
allow_booking_modifications: 'true',
|
||||
booking_modification_hours_before: '2',
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -57,6 +61,8 @@ export function AdminSettingsManagement() {
|
||||
booking_end_time: '22:00',
|
||||
allow_weekend_bookings: 'true',
|
||||
max_bookings_per_user_per_hour_per_day: '1',
|
||||
allow_booking_modifications: 'true',
|
||||
booking_modification_hours_before: '2',
|
||||
};
|
||||
|
||||
// Map the settings array to our object
|
||||
@@ -266,6 +272,43 @@ export function AdminSettingsManagement() {
|
||||
<p className='text-sm text-gray-500'>Maximum bookings per user per hour on the same day</p>
|
||||
</div>
|
||||
|
||||
{/* Booking Modification Settings */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Switch
|
||||
id='allow_booking_modifications'
|
||||
checked={settings.allow_booking_modifications === 'true'}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
updateSetting('allow_booking_modifications', checked.toString())
|
||||
}
|
||||
/>
|
||||
<Label htmlFor='allow_booking_modifications'>Allow Booking Modifications</Label>
|
||||
</div>
|
||||
<p className='text-sm text-gray-500'>Whether users can edit or cancel their bookings</p>
|
||||
</div>
|
||||
|
||||
{/* Modification Time Restriction */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='booking_modification_hours_before'>
|
||||
Modification Time Limit (hours before session)
|
||||
</Label>
|
||||
<Input
|
||||
id='booking_modification_hours_before'
|
||||
type='number'
|
||||
min='0.5'
|
||||
max='48'
|
||||
step='0.5'
|
||||
value={settings.booking_modification_hours_before}
|
||||
onChange={(e) => updateSetting('booking_modification_hours_before', e.target.value)}
|
||||
disabled={settings.allow_booking_modifications !== 'true'}
|
||||
/>
|
||||
<p className='text-sm text-gray-500'>
|
||||
{settings.allow_booking_modifications === 'true'
|
||||
? 'How many hours before a session users can still modify bookings'
|
||||
: 'Enable booking modifications to configure this setting'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Weekend Bookings */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
@@ -307,6 +350,12 @@ export function AdminSettingsManagement() {
|
||||
<strong>Booking Limit:</strong> {settings.max_bookings_per_user_per_hour_per_day} per
|
||||
hour
|
||||
</p>
|
||||
<p>
|
||||
<strong>Booking Modifications:</strong>{' '}
|
||||
{settings.allow_booking_modifications === 'true' ? 'Enabled' : 'Disabled'}
|
||||
{settings.allow_booking_modifications === 'true' &&
|
||||
` (${settings.booking_modification_hours_before}h before)`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Plus, Edit, Trash2, Clock } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getWeekDays } from '@/lib/utils';
|
||||
|
||||
interface TimeSlot {
|
||||
id: string;
|
||||
@@ -21,7 +22,9 @@ interface TimeSlot {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
// Use Irish week order (Monday first)
|
||||
const DAYS = getWeekDays().map((day) => day.label);
|
||||
const IRISH_DAY_ORDER = [1, 2, 3, 4, 5, 6, 0]; // Monday-Sunday in JS getDay() values
|
||||
|
||||
export function AdminTimeSlotManagement() {
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||
@@ -29,7 +32,7 @@ export function AdminTimeSlotManagement() {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingSlot, setEditingSlot] = useState<TimeSlot | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
dayOfWeek: 0,
|
||||
dayOfWeek: 1, // Default to Monday (Irish standard)
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
isActive: true,
|
||||
@@ -208,7 +211,7 @@ export function AdminTimeSlotManagement() {
|
||||
const resetForm = () => {
|
||||
setEditingSlot(null);
|
||||
setFormData({
|
||||
dayOfWeek: 0,
|
||||
dayOfWeek: 1, // Default to Monday (Irish standard)
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
isActive: true,
|
||||
@@ -255,9 +258,9 @@ export function AdminTimeSlotManagement() {
|
||||
<SelectValue placeholder='Select day' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS.map((day, index) => (
|
||||
<SelectItem key={index} value={index.toString()}>
|
||||
{day}
|
||||
{IRISH_DAY_ORDER.map((jsDayOfWeek, displayIndex) => (
|
||||
<SelectItem key={jsDayOfWeek} value={jsDayOfWeek.toString()}>
|
||||
{DAYS[displayIndex]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -309,75 +312,80 @@ export function AdminTimeSlotManagement() {
|
||||
<div className='text-center py-4'>Loading time slots...</div>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
{DAYS.map((day, dayIndex) => (
|
||||
<div key={dayIndex} className='space-y-2'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h3 className='font-semibold text-lg'>{day}</h3>
|
||||
{groupedTimeSlots[dayIndex]?.length > 0 && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleWipeDay(dayIndex)}
|
||||
className='text-red-600 hover:text-red-700 hover:bg-red-50'
|
||||
disabled={loading}
|
||||
>
|
||||
<Trash2 className='h-4 w-4 mr-1' />
|
||||
Wipe All
|
||||
</Button>
|
||||
{IRISH_DAY_ORDER.map((jsDayOfWeek, displayIndex) => {
|
||||
const dayName = DAYS[displayIndex];
|
||||
return (
|
||||
<div key={jsDayOfWeek} className='space-y-2'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h3 className='font-semibold text-lg'>{dayName}</h3>
|
||||
{groupedTimeSlots[jsDayOfWeek]?.length > 0 && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleWipeDay(jsDayOfWeek)}
|
||||
className='text-destructive hover:text-destructive/80 hover:bg-destructive/10'
|
||||
disabled={loading}
|
||||
>
|
||||
<Trash2 className='h-4 w-4 mr-1' />
|
||||
Wipe All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{groupedTimeSlots[jsDayOfWeek]?.length > 0 ? (
|
||||
<div className='grid gap-2'>
|
||||
{groupedTimeSlots[jsDayOfWeek]
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
.map((slot) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg ${
|
||||
slot.isActive
|
||||
? 'bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800'
|
||||
: 'bg-muted border-border'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<div className='font-medium'>
|
||||
{slot.startTime} - {slot.endTime}
|
||||
</div>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
slot.isActive
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{slot.isActive ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleEdit(slot)}
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleDelete(slot.id)}
|
||||
className='text-destructive hover:text-destructive/80'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground italic'>
|
||||
No time slots configured for {dayName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{groupedTimeSlots[dayIndex]?.length > 0 ? (
|
||||
<div className='grid gap-2'>
|
||||
{groupedTimeSlots[dayIndex]
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
.map((slot) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg ${
|
||||
slot.isActive
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<div className='font-medium'>
|
||||
{slot.startTime} - {slot.endTime}
|
||||
</div>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
slot.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{slot.isActive ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleEdit(slot)}
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleDelete(slot.id)}
|
||||
className='text-red-600 hover:text-red-700'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-gray-500 italic'>No time slots configured for {day}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -395,7 +395,7 @@ export function AdminUserManagement() {
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Calendar className='h-4 w-4 text-gray-500' />
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
{new Date(user.createdAt).toLocaleDateString('en-IE')}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AdminRecentBookings } from './AdminRecentBookings';
|
||||
import { AdminCourtManagement } from './AdminCourtManagement';
|
||||
import { AdminSettingsManagement } from './AdminSettingsManagement';
|
||||
import { AdminTimeSlotManagement } from './AdminTimeSlotManagement';
|
||||
import { ModeToggle } from '@/components/ui/mode-toggle';
|
||||
|
||||
interface AdminStats {
|
||||
totalUsers: number;
|
||||
@@ -74,20 +75,19 @@ export function AdminDashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='min-h-screen bg-background'>
|
||||
{/* Header */}
|
||||
<header className='bg-white border-b border-gray-200'>
|
||||
<header className='bg-card border-b border-border'>
|
||||
<div className='container mx-auto px-4'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center space-x-4'>
|
||||
<Shield className='h-6 w-6 text-blue-600' />
|
||||
<h1 className='text-xl font-semibold text-gray-900'>Admin Dashboard</h1>
|
||||
<Shield className='h-6 w-6 text-blue-600 dark:text-blue-400' />
|
||||
<h1 className='text-xl font-semibold text-foreground'>Admin Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-4'>
|
||||
<Badge variant='secondary' className='bg-blue-100 text-blue-800'>
|
||||
Administrator
|
||||
</Badge>
|
||||
<Badge variant='secondary'>Administrator</Badge>
|
||||
<ModeToggle />
|
||||
<Button variant='ghost' size='sm' onClick={handleLogout}>
|
||||
<LogOut className='h-4 w-4' />
|
||||
Logout
|
||||
|
||||
@@ -59,22 +59,22 @@ export function AnnouncementsList() {
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return <AlertCircle className='h-4 w-4 text-red-500' />;
|
||||
return <AlertCircle className='h-4 w-4 text-destructive' />;
|
||||
case 'medium':
|
||||
return <AlertTriangle className='h-4 w-4 text-yellow-500' />;
|
||||
return <AlertTriangle className='h-4 w-4 text-amber-500 dark:text-amber-400' />;
|
||||
default:
|
||||
return <Info className='h-4 w-4 text-blue-500' />;
|
||||
return <Info className='h-4 w-4 text-primary' />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
return 'bg-destructive/10 text-destructive border-destructive/20 dark:bg-destructive/20';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
return 'bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-800/30';
|
||||
default:
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
return 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,13 +91,15 @@ export function AnnouncementsList() {
|
||||
{announcements
|
||||
.filter((a) => a.isActive)
|
||||
.map((announcement) => (
|
||||
<div key={announcement.id} className='p-4 border rounded-lg bg-gray-50'>
|
||||
<div key={announcement.id} className='p-4 border rounded-lg bg-card'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex items-start gap-2 flex-1'>
|
||||
{getPriorityIcon(announcement.priority)}
|
||||
<div className='space-y-1'>
|
||||
<h4 className='font-medium text-sm'>{announcement.title}</h4>
|
||||
<p className='text-sm text-gray-600'>{announcement.content}</p>
|
||||
<h4 className='font-medium text-sm text-foreground'>
|
||||
{announcement.title}
|
||||
</h4>
|
||||
<p className='text-sm text-muted-foreground'>{announcement.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -111,8 +113,8 @@ export function AnnouncementsList() {
|
||||
))}
|
||||
|
||||
{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' />
|
||||
<div className='text-center py-8 text-muted-foreground'>
|
||||
<Bell className='h-8 w-8 mx-auto mb-2 text-muted-foreground/30' />
|
||||
<p>No announcements at this time</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -468,24 +468,28 @@ export function EnhancedBookingCalendar() {
|
||||
size='sm'
|
||||
onClick={() => setSelectedDate(date)}
|
||||
className={`h-16 flex flex-col relative transition-all ${
|
||||
isSelectedDate && !isTodayDate
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: ''
|
||||
} ${
|
||||
isTodayDate && !isSelectedDate
|
||||
? 'ring-2 ring-blue-400 ring-opacity-50 bg-blue-50 border-blue-200 hover:bg-blue-100'
|
||||
? 'ring-2 ring-blue-400 ring-opacity-50 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900 text-foreground'
|
||||
: ''
|
||||
} ${
|
||||
isSelectedDate && isTodayDate
|
||||
? 'bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800'
|
||||
? 'bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 dark:from-blue-700 dark:to-blue-800 dark:hover:from-blue-800 dark:hover:to-blue-900 text-white'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isTodayDate && (
|
||||
<div className='absolute -top-1 -right-1 w-3 h-3 bg-orange-500 rounded-full animate-pulse' />
|
||||
<div className='absolute -top-1 -right-1 w-3 h-3 bg-orange-500 dark:bg-orange-400 rounded-full animate-pulse' />
|
||||
)}
|
||||
<span className='text-xs font-normal'>
|
||||
{date.toLocaleDateString('en-US', { weekday: 'short' })}
|
||||
{date.toLocaleDateString('en-IE', { weekday: 'short' })}
|
||||
</span>
|
||||
<span className='font-semibold'>{date.getDate()}</span>
|
||||
<span className='text-xs font-normal'>
|
||||
{date.toLocaleDateString('en-US', { month: 'short' })}
|
||||
{date.toLocaleDateString('en-IE', { month: 'short' })}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
@@ -497,16 +501,16 @@ export function EnhancedBookingCalendar() {
|
||||
<div
|
||||
className={`text-center p-4 rounded-lg ${
|
||||
isToday(selectedDate)
|
||||
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white'
|
||||
: 'bg-blue-50'
|
||||
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white dark:from-blue-600 dark:to-indigo-700'
|
||||
: 'bg-blue-50 dark:bg-blue-950'
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
className={`text-lg font-semibold ${
|
||||
isToday(selectedDate) ? 'text-white' : 'text-blue-900'
|
||||
isToday(selectedDate) ? 'text-primary-foreground' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{selectedDate.toLocaleDateString('en-US', {
|
||||
{selectedDate.toLocaleDateString('en-IE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -515,9 +519,9 @@ export function EnhancedBookingCalendar() {
|
||||
</h3>
|
||||
{isToday(selectedDate) && (
|
||||
<div className='flex items-center justify-center gap-2 mt-2'>
|
||||
<div className='w-2 h-2 bg-orange-300 rounded-full animate-pulse' />
|
||||
<span className='text-sm font-medium text-blue-100'>Today</span>
|
||||
<div className='w-2 h-2 bg-orange-300 rounded-full animate-pulse' />
|
||||
<div className='w-2 h-2 bg-orange-300 dark:bg-orange-400 rounded-full animate-pulse' />
|
||||
<span className='text-sm font-medium text-blue-100 dark:text-blue-200'>Today</span>
|
||||
<div className='w-2 h-2 bg-orange-300 dark:bg-orange-400 rounded-full animate-pulse' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -525,15 +529,15 @@ export function EnhancedBookingCalendar() {
|
||||
{/* 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 className='inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary'></div>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>Loading booking slots...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Courts Available */}
|
||||
{!loading && courts.length === 0 && (
|
||||
<div className='text-center py-8'>
|
||||
<p className='text-gray-500'>No courts available for booking</p>
|
||||
<p className='text-muted-foreground'>No courts available for booking</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -550,7 +554,7 @@ export function EnhancedBookingCalendar() {
|
||||
|
||||
return (
|
||||
<div key={time} className='space-y-2'>
|
||||
<div className='flex items-center gap-2 text-sm font-medium text-gray-700'>
|
||||
<div className='flex items-center gap-2 text-sm font-medium text-foreground'>
|
||||
<Clock className='h-4 w-4' />
|
||||
{time} -{' '}
|
||||
{String(parseInt(time.split(':')[0]) + 1).padStart(2, '0')}:00
|
||||
@@ -565,27 +569,27 @@ export function EnhancedBookingCalendar() {
|
||||
{slotsForTime.map((slot) => (
|
||||
<div
|
||||
key={`${slot.courtId}-${slot.time}`}
|
||||
className={`p-3 border rounded-lg transition-colors cursor-pointer ${
|
||||
className={`p-3 border rounded-lg transition-all duration-200 ${
|
||||
slot.available
|
||||
? 'border-green-200 bg-green-50 hover:bg-green-100'
|
||||
: 'border-red-200 bg-red-50 cursor-not-allowed'
|
||||
? 'border-green-200 bg-green-50 hover:bg-green-100 hover:shadow-sm cursor-pointer dark:border-green-700 dark:bg-green-950 dark:hover:bg-green-900'
|
||||
: 'border-muted bg-muted/50 cursor-not-allowed opacity-75 hover:opacity-100'
|
||||
}`}
|
||||
onClick={() => handleSlotClick(slot)}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1 flex-1'>
|
||||
<div className='flex items-center gap-2 text-sm font-medium'>
|
||||
<div className='flex items-center gap-2 text-sm font-medium text-foreground'>
|
||||
<MapPin className='h-4 w-4' />
|
||||
{slot.courtName}
|
||||
</div>
|
||||
{!slot.available && slot.bookedBy && (
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2 text-xs text-red-600'>
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
<Users className='h-3 w-3' />
|
||||
Booked by {slot.bookedBy}
|
||||
</div>
|
||||
{slot.partner && (
|
||||
<div className='flex items-center gap-2 text-xs text-orange-600'>
|
||||
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
|
||||
<User className='h-3 w-3' />
|
||||
Playing with: {slot.partner}
|
||||
</div>
|
||||
@@ -593,7 +597,7 @@ export function EnhancedBookingCalendar() {
|
||||
</div>
|
||||
)}
|
||||
{!slot.available && !slot.bookedBy && (
|
||||
<div className='text-xs text-red-600'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Already booked
|
||||
</div>
|
||||
)}
|
||||
@@ -601,10 +605,13 @@ export function EnhancedBookingCalendar() {
|
||||
<Button
|
||||
size='sm'
|
||||
disabled={!slot.available}
|
||||
variant={
|
||||
slot.available ? 'default' : 'secondary'
|
||||
}
|
||||
className={
|
||||
slot.available
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: ''
|
||||
? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 border-0'
|
||||
: 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
>
|
||||
{slot.available ? 'Book' : 'Booked'}
|
||||
@@ -625,16 +632,16 @@ export function EnhancedBookingCalendar() {
|
||||
<div className='text-center py-8'>
|
||||
{!isDayBookable() ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-red-600 font-medium'>
|
||||
<div className='text-destructive font-medium'>
|
||||
No courts available on {getDayName(selectedDate.getDay())}s
|
||||
</div>
|
||||
<p className='text-gray-500 text-sm'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
This facility is closed on {getDayName(selectedDate.getDay())}s. Please
|
||||
select a different day to make a booking.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-gray-500'>No booking slots available for this date</p>
|
||||
<p className='text-muted-foreground'>No booking slots available for this date</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -650,21 +657,21 @@ export function EnhancedBookingCalendar() {
|
||||
</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'>
|
||||
<div className='bg-primary/5 border border-primary/20 p-4 rounded-lg space-y-2 dark:bg-primary/10 dark:border-primary/30'>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
{selectedDate.toLocaleDateString('en-US', {
|
||||
{selectedDate.toLocaleDateString('en-IE', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<Clock className='h-4 w-4' />
|
||||
{selectedSlot.time} -{' '}
|
||||
{String(parseInt(selectedSlot.time.split(':')[0]) + 1).padStart(2, '0')}:00
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<div className='flex items-center gap-2 text-sm text-foreground'>
|
||||
<MapPin className='h-4 w-4' />
|
||||
{selectedSlot.courtName}
|
||||
</div>
|
||||
@@ -674,7 +681,7 @@ export function EnhancedBookingCalendar() {
|
||||
<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' />
|
||||
<User className='absolute left-3 top-3 h-4 w-4 text-muted-foreground' />
|
||||
<Input
|
||||
id='partner'
|
||||
placeholder='Who will you be playing with?'
|
||||
@@ -683,7 +690,9 @@ export function EnhancedBookingCalendar() {
|
||||
className='pl-10'
|
||||
/>
|
||||
</div>
|
||||
<p className='text-xs text-gray-500'>Enter the name of the person you'll be playing with</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
Enter the name of the person you'll be playing with
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
|
||||
@@ -44,10 +44,15 @@ export function UserBookingManagement() {
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
const [editPartner, setEditPartner] = useState('');
|
||||
const [settings, setSettings] = useState<{
|
||||
allow_booking_modifications: string;
|
||||
booking_modification_hours_before: string;
|
||||
} | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
@@ -79,6 +84,34 @@ export function UserBookingManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const settingsMap = {
|
||||
allow_booking_modifications: 'true',
|
||||
booking_modification_hours_before: '2',
|
||||
};
|
||||
|
||||
data.settings?.forEach((setting: any) => {
|
||||
if (setting.key in settingsMap) {
|
||||
settingsMap[setting.key as keyof typeof settingsMap] = setting.value;
|
||||
}
|
||||
});
|
||||
|
||||
setSettings(settingsMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
// Use default settings if fetch fails
|
||||
setSettings({
|
||||
allow_booking_modifications: 'true',
|
||||
booking_modification_hours_before: '2',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const parseBookingNotes = (notes?: string) => {
|
||||
if (!notes) return { partner: '', additionalNotes: '' };
|
||||
|
||||
@@ -205,13 +238,19 @@ export function UserBookingManagement() {
|
||||
};
|
||||
|
||||
const canModifyBooking = (booking: Booking) => {
|
||||
if (!settings || settings.allow_booking_modifications !== 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bookingDateTime = new Date(`${booking.date}T${booking.startTime}`);
|
||||
const now = new Date();
|
||||
const timeDiff = bookingDateTime.getTime() - now.getTime();
|
||||
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||
|
||||
// Allow modifications if booking is more than 2 hours away
|
||||
return hoursDiff > 2;
|
||||
const requiredHours = parseFloat(settings.booking_modification_hours_before) || 2;
|
||||
|
||||
// Allow modifications if booking is more than the required hours away
|
||||
return hoursDiff > requiredHours;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -227,8 +266,8 @@ export function UserBookingManagement() {
|
||||
<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 className='h-4 bg-muted rounded w-3/4 mb-2'></div>
|
||||
<div className='h-3 bg-muted rounded w-1/2'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -251,7 +290,7 @@ export function UserBookingManagement() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bookings.length === 0 ? (
|
||||
<div className='text-sm text-gray-500 text-center py-6'>
|
||||
<div className='text-sm text-muted-foreground text-center py-6'>
|
||||
No upcoming bookings. Make your first booking!
|
||||
</div>
|
||||
) : (
|
||||
@@ -265,19 +304,19 @@ export function UserBookingManagement() {
|
||||
<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' />
|
||||
<MapPin className='h-4 w-4 text-primary' />
|
||||
<span className='font-medium text-sm'>{booking.court.name}</span>
|
||||
{isToday(booking.date) && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='text-xs bg-gradient-to-r from-orange-100 to-orange-200 text-orange-700 border-orange-300'
|
||||
className='text-xs bg-orange-100 text-orange-700 border-orange-300 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-800'
|
||||
>
|
||||
🎯 Today
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-4 text-xs text-gray-500'>
|
||||
<div className='flex items-center gap-4 text-xs text-muted-foreground'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar className='h-3 w-3' />
|
||||
<span>{formatDate(booking.date)}</span>
|
||||
@@ -291,14 +330,14 @@ export function UserBookingManagement() {
|
||||
</div>
|
||||
|
||||
{partner && (
|
||||
<div className='flex items-center gap-1 text-xs text-gray-600'>
|
||||
<div className='flex items-center gap-1 text-xs text-muted-foreground'>
|
||||
<User className='h-3 w-3' />
|
||||
<span>Playing with: {partner}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{additionalNotes && (
|
||||
<p className='text-xs text-gray-600 italic bg-gray-50 p-2 rounded'>
|
||||
<p className='text-xs text-muted-foreground italic bg-muted p-2 rounded'>
|
||||
{additionalNotes}
|
||||
</p>
|
||||
)}
|
||||
@@ -319,7 +358,7 @@ export function UserBookingManagement() {
|
||||
variant='outline'
|
||||
onClick={() => handleDeleteClick(booking)}
|
||||
disabled={!canModify}
|
||||
className='h-8 w-8 p-0 text-red-600 hover:text-red-700'
|
||||
className='h-8 w-8 p-0 text-destructive hover:text-destructive/80'
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
@@ -327,8 +366,12 @@ export function UserBookingManagement() {
|
||||
</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 className='text-xs text-amber-600 bg-amber-50 p-2 rounded dark:text-amber-400 dark:bg-amber-950'>
|
||||
{settings?.allow_booking_modifications !== 'true'
|
||||
? 'Booking modifications are currently disabled by administrator'
|
||||
: `Booking can only be modified more than ${
|
||||
settings?.booking_modification_hours_before || '2'
|
||||
} hours before the session`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -347,7 +390,7 @@ export function UserBookingManagement() {
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
{selectedBooking && (
|
||||
<div className='bg-blue-50 p-4 rounded-lg space-y-2'>
|
||||
<div className='bg-blue-50 p-4 rounded-lg space-y-2 dark:bg-blue-950'>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
{formatDate(selectedBooking.date)}
|
||||
@@ -366,7 +409,7 @@ export function UserBookingManagement() {
|
||||
<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' />
|
||||
<User className='absolute left-3 top-3 h-4 w-4 text-muted-foreground' />
|
||||
<Input
|
||||
id='edit-partner'
|
||||
placeholder='Who will you be playing with?'
|
||||
@@ -408,11 +451,11 @@ export function UserBookingManagement() {
|
||||
<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'>
|
||||
<div className='mt-3 p-3 bg-muted rounded'>
|
||||
<p className='text-sm font-medium'>
|
||||
{selectedBooking.court.name} - {formatDate(selectedBooking.date)}
|
||||
</p>
|
||||
<p className='text-sm text-gray-600'>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{selectedBooking.startTime} - {selectedBooking.endTime}
|
||||
</p>
|
||||
</div>
|
||||
@@ -421,7 +464,10 @@ export function UserBookingManagement() {
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm} className='bg-red-600 hover:bg-red-700'>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
Cancel Booking
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function BookingCalendar() {
|
||||
const [selectedCourt, setSelectedCourt] = useState<string | null>(null);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
return date.toLocaleDateString('en-IE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
@@ -145,13 +145,17 @@ export function BookingCalendar() {
|
||||
size='sm'
|
||||
onClick={() => !isBooked && setSelectedSlot(time)}
|
||||
disabled={isBooked}
|
||||
className='relative'
|
||||
className={`relative transition-all ${
|
||||
isBooked
|
||||
? 'opacity-60 cursor-not-allowed bg-muted/50 border-muted text-muted-foreground hover:opacity-75'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
{isBooked && (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute -top-1 -right-1 h-2 w-2 p-0'
|
||||
variant='secondary'
|
||||
className='absolute -top-1 -right-1 h-2 w-2 p-0 bg-muted border-muted'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
@@ -162,9 +166,9 @@ export function BookingCalendar() {
|
||||
|
||||
{/* 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='bg-primary/5 border border-primary/20 rounded-lg p-4 space-y-2 dark:bg-primary/10 dark:border-primary/30'>
|
||||
<h4 className='font-medium text-primary dark:text-primary-foreground'>Booking Summary</h4>
|
||||
<div className='text-sm text-primary/80 dark:text-primary-foreground/80 space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<CalendarIcon className='h-3 w-3' />
|
||||
{formatDate(selectedDate)}
|
||||
|
||||
@@ -85,16 +85,16 @@ export function RecentBookings() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bookings.length === 0 ? (
|
||||
<div className='text-sm text-gray-500 text-center py-6'>
|
||||
<div className='text-sm text-muted-foreground text-center py-6'>
|
||||
No recent bookings yet. Make your first booking!
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{bookings.map((booking) => (
|
||||
<div key={booking.id} className='border rounded-lg p-3 space-y-2'>
|
||||
<div key={booking.id} className='border border-border rounded-lg p-3 space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MapPin className='h-4 w-4 text-blue-600' />
|
||||
<MapPin className='h-4 w-4 text-primary' />
|
||||
<span className='font-medium text-sm'>{booking.court.name}</span>
|
||||
</div>
|
||||
<Badge variant={booking.status === 'active' ? 'default' : 'secondary'}>
|
||||
@@ -102,7 +102,7 @@ export function RecentBookings() {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-4 text-xs text-gray-500'>
|
||||
<div className='flex items-center gap-4 text-xs text-muted-foreground'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar className='h-3 w-3' />
|
||||
<span>{formatDate(booking.date)}</span>
|
||||
@@ -115,7 +115,9 @@ export function RecentBookings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.notes && <p className='text-xs text-gray-600 italic'>{booking.notes}</p>}
|
||||
{booking.notes && (
|
||||
<p className='text-xs text-muted-foreground italic'>{booking.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Bell, LogOut, Settings, User, Calendar } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { NotificationBell, AnnouncementsModal } from '@/components/notifications/announcements';
|
||||
import { UserProfile } from '@/components/user/user-profile';
|
||||
import { ModeToggle } from '@/components/ui/mode-toggle';
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
user: {
|
||||
@@ -71,24 +72,22 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className='bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-50'>
|
||||
<header className='bg-background/80 backdrop-blur-md border-b border-border sticky top-0 z-50'>
|
||||
<div className='container mx-auto px-4'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center space-x-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Calendar className='h-6 w-6 text-blue-600' />
|
||||
<h1 className='text-xl font-bold text-gray-900'>TT Booking</h1>
|
||||
<Calendar className='h-6 w-6 text-primary' />
|
||||
<h1 className='text-xl font-bold text-foreground'>TT Booking</h1>
|
||||
</div>
|
||||
{user.role === 'admin' && (
|
||||
<Badge variant='secondary' className='bg-purple-100 text-purple-800'>
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
{user.role === 'admin' && <Badge variant='secondary'>Admin</Badge>}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-4'>
|
||||
<NotificationBell unreadCount={unreadCount} onClick={() => setShowAnnouncements(true)} />
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Button variant='ghost' size='sm' onClick={() => router.push('/admin')}>
|
||||
<Settings className='h-4 w-4 mr-2' />
|
||||
@@ -102,8 +101,8 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
|
||||
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 className='h-4 w-4 text-muted-foreground' />
|
||||
<span className='text-sm text-foreground'>
|
||||
{user.name && user.surname ? `${user.name} ${user.surname}` : user.email.split('@')[0]}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -65,27 +65,27 @@ export function AnnouncementsModal({ isOpen, onClose, unreadCount, onCountUpdate
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return <AlertCircle className='h-4 w-4 text-red-500' />;
|
||||
return <AlertCircle className='h-4 w-4 text-destructive' />;
|
||||
case 'medium':
|
||||
return <AlertTriangle className='h-4 w-4 text-yellow-500' />;
|
||||
return <AlertTriangle className='h-4 w-4 text-amber-500 dark:text-amber-400' />;
|
||||
default:
|
||||
return <Info className='h-4 w-4 text-blue-500' />;
|
||||
return <Info className='h-4 w-4 text-primary' />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'border-red-200 bg-red-50';
|
||||
return 'border-destructive/20 bg-destructive/5';
|
||||
case 'medium':
|
||||
return 'border-yellow-200 bg-yellow-50';
|
||||
return 'border-amber-500/20 bg-amber-500/5 dark:border-amber-400/20 dark:bg-amber-400/5';
|
||||
default:
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
return 'border-primary/20 bg-primary/5';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
return new Date(dateStr).toLocaleDateString('en-IE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -112,12 +112,12 @@ export function AnnouncementsModal({ isOpen, onClose, unreadCount, onCountUpdate
|
||||
<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 className='animate-spin rounded-full h-8 w-8 border-b-2 border-foreground'></div>
|
||||
<p className='ml-2 text-foreground'>Loading announcements...</p>
|
||||
</div>
|
||||
) : announcements.length === 0 ? (
|
||||
<div className='text-center py-8 text-gray-500'>
|
||||
<Bell className='h-12 w-12 mx-auto mb-4 text-gray-300' />
|
||||
<div className='text-center py-8 text-muted-foreground'>
|
||||
<Bell className='h-12 w-12 mx-auto mb-4 text-muted-foreground/50' />
|
||||
<p>No announcements at this time</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -137,8 +137,10 @@ export function AnnouncementsModal({ isOpen, onClose, unreadCount, onCountUpdate
|
||||
</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>
|
||||
<p className='text-sm text-foreground mb-2'>{announcement.content}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{formatDate(announcement.createdAt)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -7,3 +7,50 @@ import { type ThemeProviderProps } from 'next-themes/dist/types';
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
|
||||
// Enhanced hook for theme management with database sync
|
||||
export function useThemeWithSync() {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [userTheme, setUserTheme] = React.useState<'light' | 'dark' | 'system'>('system');
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
fetchUserTheme();
|
||||
}, []);
|
||||
|
||||
const fetchUserTheme = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/users/theme');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserTheme(data.themePreference);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user theme preference:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = async (theme: 'light' | 'dark' | 'system') => {
|
||||
try {
|
||||
const response = await fetch('/api/users/theme', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ themePreference: theme }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUserTheme(theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update theme preference:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mounted,
|
||||
theme: userTheme,
|
||||
setTheme: updateTheme,
|
||||
};
|
||||
}
|
||||
|
||||
+149
-198
@@ -1,213 +1,164 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
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"
|
||||
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
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
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} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DayPicker
|
||||
weekStartsOn={1} // Monday as first day for Ireland
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString('en-IE', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]',
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
'flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border',
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn('bg-popover absolute inset-0 opacity-0', defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal',
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn('w-[--cell-size] select-none', defaultClassNames.week_number_header),
|
||||
week_number: cn('text-muted-foreground select-none text-[0.8rem]', defaultClassNames.week_number),
|
||||
day: cn(
|
||||
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn('bg-accent rounded-l-md', defaultClassNames.range_start),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot='calendar' ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
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()
|
||||
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])
|
||||
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}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
data-day={day.date.toLocaleDateString('en-IE')}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
export { Calendar, CalendarDayButton };
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const handleThemeChange = async (newTheme: 'light' | 'dark' | 'system') => {
|
||||
// Update next-themes immediately for UI responsiveness
|
||||
setTheme(newTheme);
|
||||
|
||||
// Sync with database in background
|
||||
try {
|
||||
await fetch('/api/users/theme', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ themePreference: newTheme }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sync theme preference:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='icon'>
|
||||
<Sun className='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
|
||||
<Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => handleThemeChange('light')}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleThemeChange('dark')}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleThemeChange('system')}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,10 @@ 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 { User, Edit, Mail, Calendar, Save, X, Palette } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { ModeToggle } from '@/components/ui/mode-toggle';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -15,6 +17,7 @@ interface User {
|
||||
surname: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
themePreference: 'light' | 'dark' | 'system';
|
||||
}
|
||||
|
||||
interface ProfileFormData {
|
||||
@@ -32,6 +35,7 @@ export function UserProfile() {
|
||||
surname: '',
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const updateFormData = (field: keyof ProfileFormData, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
@@ -54,6 +58,10 @@ export function UserProfile() {
|
||||
name: userData.user.name,
|
||||
surname: userData.user.surname,
|
||||
});
|
||||
// Sync theme with user preference if available
|
||||
if (userData.user.themePreference && userData.user.themePreference !== theme) {
|
||||
setTheme(userData.user.themePreference);
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
@@ -139,8 +147,8 @@ export function UserProfile() {
|
||||
<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 className='animate-spin rounded-full h-8 w-8 border-b-2 border-primary'></div>
|
||||
<p className='ml-2 text-muted-foreground'>Loading profile...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -151,7 +159,7 @@ export function UserProfile() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className='p-6'>
|
||||
<div className='text-center text-gray-500'>
|
||||
<div className='text-center text-muted-foreground'>
|
||||
<p>Unable to load user profile</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -182,7 +190,7 @@ export function UserProfile() {
|
||||
placeholder='Enter your first name'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
|
||||
<div className='flex items-center gap-2 p-2 bg-muted rounded'>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -199,7 +207,7 @@ export function UserProfile() {
|
||||
placeholder='Enter your last name'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
|
||||
<div className='flex items-center gap-2 p-2 bg-muted rounded'>
|
||||
<span>{user.surname}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -208,20 +216,20 @@ export function UserProfile() {
|
||||
{/* 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'>
|
||||
<div className='flex items-center gap-2 p-2 bg-muted rounded text-muted-foreground'>
|
||||
<Mail className='h-4 w-4' />
|
||||
<span>{user.email}</span>
|
||||
<span className='text-xs text-gray-500 ml-auto'>(Read-only)</span>
|
||||
<span className='text-xs text-muted-foreground/60 ml-auto'>(Read-only)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member Since */}
|
||||
<div className='space-y-2'>
|
||||
<Label>Member Since</Label>
|
||||
<div className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
|
||||
<div className='flex items-center gap-2 p-2 bg-muted rounded'>
|
||||
<Calendar className='h-4 w-4' />
|
||||
<span>
|
||||
{new Date(user.createdAt).toLocaleDateString('en-US', {
|
||||
{new Date(user.createdAt).toLocaleDateString('en-IE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
@@ -268,13 +276,49 @@ export function UserProfile() {
|
||||
<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'>
|
||||
<div className='p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-blue-800 dark:text-blue-200 capitalize font-medium'>
|
||||
{user.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>User ID</Label>
|
||||
<div className='p-2 bg-gray-50 rounded text-gray-600 font-mono text-sm'>{user.id}</div>
|
||||
<div className='p-2 bg-muted rounded text-muted-foreground font-mono text-sm'>
|
||||
{user.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Theme Preferences Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Palette className='h-5 w-5' />
|
||||
Theme Preferences
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<Label>App Theme</Label>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Choose how the app appears to you. System will use your device's theme setting.
|
||||
</p>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
<div className='p-3 bg-muted/50 rounded-lg'>
|
||||
<p className='text-sm'>
|
||||
<strong>Current theme:</strong>{' '}
|
||||
<span className='capitalize'>{theme === 'system' ? 'System preference' : theme}</span>
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground mt-1'>
|
||||
Your theme preference is automatically saved and will be applied across all your
|
||||
sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user