timeslots API

This commit is contained in:
Asya Vee
2025-08-26 23:36:24 +04:00
parent 47baf8dfe2
commit c0647fe512
47 changed files with 830 additions and 776 deletions

View File

@@ -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 */}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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}
/>

View File

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

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

View File

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