theming, date, time localisation, additional features, seeding initial cleanup

This commit is contained in:
mikicvi
2025-09-26 21:12:59 +01:00
parent b89d91ade2
commit 22c462c61c
43 changed files with 2647 additions and 550 deletions
@@ -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>
)}
+1 -1
View File
@@ -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>
+81 -73
View File
@@ -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>
+1 -1
View File
@@ -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>
+7 -7
View File
@@ -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
+13 -11
View File
@@ -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'>
+64 -18
View File
@@ -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>
+11 -7
View File
@@ -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)}
+7 -5
View File
@@ -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 -10
View File
@@ -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>
+15 -13
View File
@@ -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>
))}
+47
View File
@@ -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
View File
@@ -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 };
+201
View File
@@ -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,
}
+52
View File
@@ -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>
);
}
+56 -12
View File
@@ -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>