timeslots API
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { BookingService } from '@/lib/bookingService';
|
||||
import { bookingApi } from '@/lib/pocketbase';
|
||||
import { BookingType, TimeSlot } from '@/types/bookings';
|
||||
import BookingTypeSelector from './BookingTypeSelector';
|
||||
import DateSelector from './DateSelector';
|
||||
import TimeSlotSelector from './TimeSlotSelector';
|
||||
@@ -15,10 +19,17 @@ interface FormData {
|
||||
}
|
||||
|
||||
const BookingInterface = () => {
|
||||
const t = useTranslations('bookingForm');
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
const [selectedTimeSlot, setSelectedTimeSlot] = useState('');
|
||||
const [selectedBookingType, setSelectedBookingType] = useState('');
|
||||
const [participantsCount, setParticipantsCount] = useState(1);
|
||||
const [bookingTypes, setBookingTypes] = useState<BookingType[]>([]);
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||
const [availableDates, setAvailableDates] = useState<string[]>([]);
|
||||
const [availableSlotsByDate, setAvailableSlotsByDate] = useState<{ [date: string]: { start_time: string; end_time: string }[] }>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { register, handleSubmit, formState: { errors, isValid }, setValue, trigger } = useForm<FormData>({
|
||||
mode: 'onChange',
|
||||
@@ -29,25 +40,30 @@ const BookingInterface = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const bookingTypes = [
|
||||
{ id: '1', name: 'wheel_rental', display_name: 'Wheel Rental', requires_payment: false, price_per_person: 0 },
|
||||
{ id: '2', name: 'hand_building', display_name: 'Hand Building Coworking', requires_payment: false, price_per_person: 0 },
|
||||
{ id: '3', name: 'personal_workshop', display_name: 'Personal Workshop', requires_payment: true, price_per_person: 75 },
|
||||
{ id: '4', name: 'group_workshop', display_name: 'Group Workshop', requires_payment: true, price_per_person: 50 }
|
||||
];
|
||||
|
||||
// Mock available time slots
|
||||
const timeSlots = [
|
||||
{ id: '1', start_time: '09:00', end_time: '11:00', available: true },
|
||||
{ id: '2', start_time: '11:30', end_time: '13:30', available: true },
|
||||
{ id: '3', start_time: '14:00', end_time: '16:00', available: false },
|
||||
{ id: '4', start_time: '16:30', end_time: '18:30', available: true },
|
||||
{ id: '5', start_time: '19:00', end_time: '21:00', available: true }
|
||||
];
|
||||
|
||||
const selectedBookingTypeData = bookingTypes.find(bt => bt.id === selectedBookingType);
|
||||
|
||||
// Fetch booking types on mount
|
||||
useEffect(() => {
|
||||
const fetchBookingTypes = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const types = await BookingService.getBookingTypes();
|
||||
setBookingTypes(types);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load booking types');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBookingTypes();
|
||||
}, []);
|
||||
|
||||
// Available dates are now handled in handleBookingTypeChange
|
||||
|
||||
// Time slots are now handled in handleDateChange
|
||||
|
||||
useEffect(() => {
|
||||
setValue('participantsCount', participantsCount);
|
||||
trigger('participantsCount');
|
||||
@@ -61,14 +77,13 @@ const BookingInterface = () => {
|
||||
customerName: data.customerName,
|
||||
customerEmail: data.customerEmail,
|
||||
startTime: selectedDate && selectedTimeSlotData
|
||||
? `${selectedDate}T${selectedTimeSlotData.start_time}:00`
|
||||
? `${selectedDate}T${selectedTimeSlotData.start_time}`
|
||||
: '',
|
||||
endTime: selectedDate && selectedTimeSlotData
|
||||
? `${selectedDate}T${selectedTimeSlotData.end_time}:00`
|
||||
? `${selectedDate}T${selectedTimeSlotData.end_time}`
|
||||
: '',
|
||||
participantsCount: data.participantsCount,
|
||||
};
|
||||
|
||||
console.log(formData);
|
||||
};
|
||||
|
||||
@@ -76,15 +91,58 @@ const BookingInterface = () => {
|
||||
setParticipantsCount(count);
|
||||
};
|
||||
|
||||
const handleBookingTypeChange = (typeId: string) => {
|
||||
const handleBookingTypeChange = async (typeId: string) => {
|
||||
setSelectedBookingType(typeId);
|
||||
setSelectedDate('');
|
||||
setSelectedTimeSlot('');
|
||||
setTimeSlots([]);
|
||||
|
||||
// Set participants count to booking type's minimum capacity
|
||||
const bookingType = bookingTypes.find(bt => bt.id === typeId);
|
||||
if (bookingType) {
|
||||
setParticipantsCount(bookingType.min_capacity);
|
||||
}
|
||||
|
||||
// Fetch time slots for the selected booking type
|
||||
try {
|
||||
const timeSlots = await bookingApi.getTimeSlotsForBookingType(typeId);
|
||||
|
||||
// Generate available time slots grouped by date
|
||||
const generatedSlotsByDate = bookingApi.generateAvailableTimeSlots(timeSlots);
|
||||
const availableDatesFromAPI = Object.keys(generatedSlotsByDate).sort();
|
||||
|
||||
// Update available dates and slots data for the date selector
|
||||
setAvailableDates(availableDatesFromAPI);
|
||||
setAvailableSlotsByDate(generatedSlotsByDate);
|
||||
} catch (error) {
|
||||
console.error('Error fetching time slots:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
setSelectedTimeSlot('');
|
||||
|
||||
// Get time slots for the selected date from availableSlotsByDate
|
||||
const slotsForDate = availableSlotsByDate[date] || [];
|
||||
console.log(`Time slots for ${date}:`, slotsForDate);
|
||||
|
||||
// Convert to TimeSlot format for TimeSlotSelector
|
||||
const formattedTimeSlots: TimeSlot[] = slotsForDate.map((slot, index) => ({
|
||||
id: `slot-${date}-${index}`,
|
||||
start_time: slot.start_time,
|
||||
end_time: slot.end_time,
|
||||
is_active: true,
|
||||
max_capacity: 8,
|
||||
booking_types: [selectedBookingType],
|
||||
is_reccuring: false,
|
||||
recurrence_pattern: undefined,
|
||||
resources: [],
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString()
|
||||
}));
|
||||
|
||||
setTimeSlots(formattedTimeSlots);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -92,22 +150,49 @@ const BookingInterface = () => {
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Book Your Session</h1>
|
||||
<p className="text-gray-600">Choose your pottery experience</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('title')}</h1>
|
||||
<p className="text-gray-600">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-600 mt-2">{t('loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
|
||||
<div className="text-red-500 mb-2">⚠️</div>
|
||||
<p className="text-gray-800 font-medium mb-2">{t('noBookings')}</p>
|
||||
<p className="text-gray-600 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Type Selection */}
|
||||
<BookingTypeSelector
|
||||
bookingTypes={bookingTypes}
|
||||
selectedBookingType={selectedBookingType}
|
||||
onBookingTypeChange={handleBookingTypeChange}
|
||||
/>
|
||||
{!loading && !error && bookingTypes.length === 0 && (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm text-center">
|
||||
<p className="text-gray-800">{t('noBookings')}</p>
|
||||
<p className="text-gray-600 text-sm mt-2">{t('checkBackLater')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && bookingTypes.length > 0 && (
|
||||
<BookingTypeSelector
|
||||
bookingTypes={bookingTypes}
|
||||
selectedBookingType={selectedBookingType}
|
||||
onBookingTypeChange={handleBookingTypeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date Selection */}
|
||||
{selectedBookingType && (
|
||||
<DateSelector
|
||||
selectedDate={selectedDate}
|
||||
availableDates={availableDates}
|
||||
onDateChange={handleDateChange}
|
||||
/>
|
||||
)}
|
||||
@@ -122,13 +207,14 @@ const BookingInterface = () => {
|
||||
)}
|
||||
|
||||
{/* Customer Details */}
|
||||
{selectedTimeSlot && (
|
||||
{selectedTimeSlot && selectedBookingTypeData && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<CustomerDetails
|
||||
register={register}
|
||||
errors={errors}
|
||||
participantsCount={participantsCount}
|
||||
onParticipantsCountChange={handleParticipantsCountChange}
|
||||
bookingType={selectedBookingTypeData}
|
||||
/>
|
||||
|
||||
{/* Summary & Submit */}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BookingType {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
requires_payment: boolean;
|
||||
price_per_person: number;
|
||||
}
|
||||
|
||||
interface TimeSlot {
|
||||
id: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
available: boolean;
|
||||
}
|
||||
import { BookingType, TimeSlot } from '@/types/bookings';
|
||||
|
||||
interface BookingSummaryProps {
|
||||
selectedBookingTypeData: BookingType | undefined;
|
||||
@@ -34,6 +20,23 @@ const BookingSummary: React.FC<BookingSummaryProps> = ({
|
||||
}) => {
|
||||
const selectedTimeSlotData = timeSlots.find(s => s.id === selectedTimeSlot);
|
||||
|
||||
// Format time to display in Tbilisi timezone (UTC+4)
|
||||
const formatTime = (time: string) => {
|
||||
if (time.match(/^\d{2}:\d{2}$/)) {
|
||||
return time;
|
||||
}
|
||||
if (time.includes('T') || time.includes(' ') || time.includes('Z')) {
|
||||
const date = new Date(time);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
timeZone: 'Asia/Tbilisi',
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
return time;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Summary</h2>
|
||||
@@ -50,7 +53,10 @@ const BookingSummary: React.FC<BookingSummaryProps> = ({
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Time:</span>
|
||||
<span className="font-medium">
|
||||
{selectedTimeSlotData?.start_time} - {selectedTimeSlotData?.end_time}
|
||||
{selectedTimeSlotData?.start_time && selectedTimeSlotData?.end_time
|
||||
? `${formatTime(selectedTimeSlotData.start_time)}—${formatTime(selectedTimeSlotData.end_time)}`
|
||||
: 'Not selected'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
interface BookingType {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
requires_payment: boolean;
|
||||
price_per_person: number;
|
||||
}
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { BookingType } from '@/types/bookings';
|
||||
|
||||
interface BookingTypeSelectorProps {
|
||||
bookingTypes: BookingType[];
|
||||
@@ -20,11 +14,13 @@ const BookingTypeSelector: React.FC<BookingTypeSelectorProps> = ({
|
||||
selectedBookingType,
|
||||
onBookingTypeChange,
|
||||
}) => {
|
||||
const t = useTranslations('bookingForm');
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Select Experience
|
||||
{t('selectExperience')}
|
||||
</h2>
|
||||
<div className="grid gap-3">
|
||||
{bookingTypes.map((type) => (
|
||||
@@ -47,13 +43,13 @@ const BookingTypeSelector: React.FC<BookingTypeSelectorProps> = ({
|
||||
<h3 className="font-medium text-gray-900">{type.display_name}</h3>
|
||||
{type.requires_payment && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
${type.price_per_person} per person
|
||||
₾{type.price_per_person} {t('perPerson')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{type.requires_payment && (
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||
Payment Required
|
||||
{t('paymentRequired')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { User, Users } from 'lucide-react';
|
||||
import { UseFormRegister, FieldErrors } from 'react-hook-form';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { BookingType } from '@/types/bookings';
|
||||
|
||||
interface FormData {
|
||||
customerName: string;
|
||||
@@ -13,6 +15,7 @@ interface CustomerDetailsProps {
|
||||
errors: FieldErrors<FormData>;
|
||||
participantsCount: number;
|
||||
onParticipantsCountChange: (count: number) => void;
|
||||
bookingType: BookingType;
|
||||
}
|
||||
|
||||
const CustomerDetails: React.FC<CustomerDetailsProps> = ({
|
||||
@@ -20,7 +23,13 @@ const CustomerDetails: React.FC<CustomerDetailsProps> = ({
|
||||
errors,
|
||||
participantsCount,
|
||||
onParticipantsCountChange,
|
||||
bookingType,
|
||||
}) => {
|
||||
const t = useTranslations('bookingForm');
|
||||
|
||||
// Calculate min and max based on booking type
|
||||
const minParticipants = bookingType.min_capacity;
|
||||
const maxParticipants = bookingType.max_capacity;
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
@@ -80,9 +89,9 @@ const CustomerDetails: React.FC<CustomerDetailsProps> = ({
|
||||
<div className="flex items-center justify-center bg-gray-100 rounded-lg p-1 max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onParticipantsCountChange(Math.max(1, participantsCount - 1))}
|
||||
className="w-12 h-12 flex items-center justify-center rounded-lg bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-800 transition-colors"
|
||||
disabled={participantsCount <= 1}
|
||||
onClick={() => onParticipantsCountChange(Math.max(minParticipants, participantsCount - 1))}
|
||||
className="w-12 h-12 flex items-center justify-center rounded-lg bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={participantsCount <= minParticipants}
|
||||
>
|
||||
<span className="text-xl font-medium">−</span>
|
||||
</button>
|
||||
@@ -92,18 +101,23 @@ const CustomerDetails: React.FC<CustomerDetailsProps> = ({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onParticipantsCountChange(Math.min(8, participantsCount + 1))}
|
||||
className="w-12 h-12 flex items-center justify-center rounded-lg bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-800 transition-colors"
|
||||
disabled={participantsCount >= 8}
|
||||
onClick={() => onParticipantsCountChange(Math.min(maxParticipants, participantsCount + 1))}
|
||||
className="w-12 h-12 flex items-center justify-center rounded-lg bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={participantsCount >= maxParticipants}
|
||||
>
|
||||
<span className="text-xl font-medium">+</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 mt-2 text-center">
|
||||
Min: {minParticipants} • Max: {maxParticipants}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
{...register('participantsCount', {
|
||||
min: { value: 1, message: 'At least 1 participant required' },
|
||||
max: { value: 8, message: 'Maximum 8 participants allowed' }
|
||||
min: { value: minParticipants, message: `At least ${minParticipants} participants required` },
|
||||
max: { value: maxParticipants, message: `Maximum ${maxParticipants} participants allowed` }
|
||||
})}
|
||||
value={participantsCount}
|
||||
/>
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface CalendarDay {
|
||||
date: string;
|
||||
day: number;
|
||||
month: string;
|
||||
dayName: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
interface DateSelectorProps {
|
||||
selectedDate: string;
|
||||
availableDates: string[];
|
||||
onDateChange: (date: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
selectedDate,
|
||||
availableDates,
|
||||
onDateChange,
|
||||
loading = false,
|
||||
}) => {
|
||||
const t = useTranslations('bookingForm');
|
||||
|
||||
const generateCalendarDays = (): CalendarDay[] => {
|
||||
const today = new Date();
|
||||
const days = [];
|
||||
const days: CalendarDay[] = [];
|
||||
|
||||
for (let i = 0; i < 14; i++) {
|
||||
// Show 30 days instead of 14 for better selection
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
const dateString = date.toISOString().split('T')[0] || '';
|
||||
|
||||
days.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
date: dateString,
|
||||
day: date.getDate(),
|
||||
month: date.toLocaleDateString('en', { month: 'short' }),
|
||||
dayName: date.toLocaleDateString('en', { weekday: 'short' })
|
||||
dayName: date.toLocaleDateString('en', { weekday: 'short' }),
|
||||
available: availableDates.includes(dateString)
|
||||
});
|
||||
}
|
||||
return days;
|
||||
@@ -40,26 +52,37 @@ const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Choose Date
|
||||
{t('chooseDate')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-7 gap-2 md:gap-3">
|
||||
{calendarDays.map((day) => (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={() => onDateChange(day.date)}
|
||||
className={`p-3 rounded-lg text-center transition-all ${
|
||||
selectedDate === day.date
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">{day.dayName}</div>
|
||||
<div className="text-sm font-semibold">{day.day}</div>
|
||||
<div className="text-xs">{day.month}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-2 text-gray-600">Loading available dates...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-7 gap-2 md:gap-3">
|
||||
{calendarDays.map((day) => (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={() => day.available && onDateChange(day.date)}
|
||||
disabled={!day.available}
|
||||
className={`p-3 rounded-lg text-center transition-all ${
|
||||
!day.available
|
||||
? 'bg-gray-50 text-gray-300 cursor-not-allowed'
|
||||
: selectedDate === day.date
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">{day.dayName}</div>
|
||||
<div className="text-sm font-semibold">{day.day}</div>
|
||||
<div className="text-xs">{day.month}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
30
apps/web/components/LanguageSwitcher.tsx
Normal file
30
apps/web/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import {useLocale, useTranslations} from 'next-intl';
|
||||
import {usePathname, useRouter} from 'next/navigation';
|
||||
import {Globe} from 'lucide-react';
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('language');
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const switchLocale = () => {
|
||||
const newLocale = locale === 'en' ? 'ka' : 'en';
|
||||
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
|
||||
router.push(newPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<button
|
||||
onClick={switchLocale}
|
||||
className="flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-2 shadow-sm hover:shadow-md transition-shadow text-sm"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>{t('current')}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +1,98 @@
|
||||
import React from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
interface TimeSlot {
|
||||
id: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
available: boolean;
|
||||
}
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { TimeSlot } from '@/types/bookings';
|
||||
|
||||
interface TimeSlotSelectorProps {
|
||||
timeSlots: TimeSlot[];
|
||||
selectedTimeSlot: string;
|
||||
onTimeSlotChange: (slotId: string) => void;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const TimeSlotSelector: React.FC<TimeSlotSelectorProps> = ({
|
||||
timeSlots,
|
||||
selectedTimeSlot,
|
||||
onTimeSlotChange,
|
||||
loading = false,
|
||||
error = null,
|
||||
}) => {
|
||||
const t = useTranslations('bookingForm');
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Available Times
|
||||
{t('availableTimes')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{timeSlots.map((slot) => (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
disabled={!slot.available}
|
||||
onClick={() => onTimeSlotChange(slot.id)}
|
||||
className={`p-3 rounded-lg text-center transition-all ${
|
||||
!slot.available
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: selectedTimeSlot === slot.id
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{slot.start_time} - {slot.end_time}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-2 text-gray-600">Loading available times...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">{error}</p>
|
||||
</div>
|
||||
) : timeSlots.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">No available time slots for this date</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{timeSlots
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
.map((slot) => {
|
||||
const isAvailable = slot.is_active;
|
||||
|
||||
// Format time to display as '11:30—12:30' in Tbilisi timezone (UTC+4)
|
||||
const formatTime = (time: string) => {
|
||||
// If it's already in HH:MM format, return as is
|
||||
if (time.match(/^\d{2}:\d{2}$/)) {
|
||||
return time;
|
||||
}
|
||||
// If it's ISO format or contains date, extract time portion in Tbilisi timezone
|
||||
if (time.includes('T') || time.includes(' ') || time.includes('Z')) {
|
||||
const date = new Date(time);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
timeZone: 'Asia/Tbilisi',
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
return time;
|
||||
};
|
||||
|
||||
const formattedTime = `${formatTime(slot.start_time)}—${formatTime(slot.end_time)}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
disabled={!isAvailable}
|
||||
onClick={() => isAvailable && onTimeSlotChange(slot.id)}
|
||||
className={`p-3 rounded-lg text-center transition-all ${
|
||||
!isAvailable
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: selectedTimeSlot === slot.id
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{formattedTime}
|
||||
</div>
|
||||
{!isAvailable && (
|
||||
<div className="text-xs mt-1">Unavailable</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user