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

@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import {NextIntlClientProvider} from 'next-intl';
import {getMessages} from 'next-intl/server';
import "../globals.css";
const geistSans = localFont({
src: "../fonts/GeistVF.woff",
variable: "--font-geist-sans",
});
const geistMono = localFont({
src: "../fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
});
export const metadata: Metadata = {
title: "Vitrify - Book Your Pottery Session",
description: "Book pottery sessions and workshops",
};
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{locale: string}>;
}) {
const {locale} = await params;
const messages = await getMessages();
return (
<html lang={locale || 'en'}>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,27 @@
import BookingInterface from "../../components/BookingForm";
import {getTranslations} from 'next-intl/server';
import LanguageSwitcher from "../../components/LanguageSwitcher";
export default async function Home() {
const t = await getTranslations('footer');
return (
<div>
<main>
<LanguageSwitcher />
<BookingInterface />
</main>
<footer className="bg-white text-black py-8 mt-12 border-t">
<div className="max-w-2xl mx-auto px-4 text-center">
<p className="text-gray-600 mb-2">{t('getInTouch')}</p>
<a
href="mailto:vitrify@asyavee.me"
className="text-blue-600 hover:text-blue-800 transition-colors font-medium"
>
vitrify@asyavee.me
</a>
</div>
</footer>
</div>
);
}

View File

@@ -12,17 +12,17 @@ const geistMono = localFont({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Vitrify - Book Your Pottery Session",
description: "Book pottery sessions and workshops",
};
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html lang="en">
<html>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>

View File

@@ -1,22 +1,5 @@
import BookingInterface from "../components/BookingForm";
import {redirect} from 'next/navigation';
export default function Home() {
return (
<div>
<main>
<BookingInterface />
</main>
<footer className="bg-white text-black py-8 mt-12">
<div className="max-w-2xl mx-auto px-4 text-center">
<p className="text-gray-600 mb-2">Get in touch</p>
<a
href="mailto:vitrify@asyavee.me"
className="text-blue-600 hover:text-blue-800 transition-colors font-medium"
>
vitrify@asyavee.me
</a>
</div>
</footer>
</div>
);
}
export default function RootPage() {
redirect('/en');
}

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

View File

@@ -0,0 +1,15 @@
import { bookingApi } from './pocketbase';
import { BookingType } from '@/types/bookings';
export class BookingService {
static async getBookingTypes(): Promise<BookingType[]> {
try {
const bookingTypes = await bookingApi.getBookingTypes();
return bookingTypes.filter(type => type.is_active);
} catch (error) {
console.error('Failed to fetch booking types:', error);
throw new Error('Unable to load booking options. Please try again later.');
}
}
}

16
apps/web/lib/i18n.ts Normal file
View File

@@ -0,0 +1,16 @@
import {getRequestConfig} from 'next-intl/server';
export default getRequestConfig(async ({requestLocale}) => {
// This typically corresponds to the `[locale]` segment
let locale = await requestLocale;
// Ensure that a valid locale is used
if (!locale || !['en', 'ka'].includes(locale)) {
locale = 'en';
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});

View File

@@ -1,5 +1,5 @@
import PocketBase from 'pocketbase';
import { BookingType, Resource, TimeSlot, Booking } from '@/types/booking';
import { BookingType, TimeSlot } from '@/types/bookings';
// Initialize PocketBase client
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090');
@@ -8,7 +8,6 @@ export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || 'http
pb.autoCancellation(false);
// API functions for booking system
export const bookingApi = {
// Get all active booking types
async getBookingTypes(): Promise<BookingType[]> {
@@ -24,121 +23,103 @@ export const bookingApi = {
}
},
// Get available resources
async getResources(): Promise<Resource[]> {
// Get time slots for a specific booking type
async getTimeSlotsForBookingType(bookingTypeId: string): Promise<TimeSlot[]> {
try {
const records = await pb.collection('resources').getFullList<Resource>({
filter: 'is_active = true',
sort: 'type',
});
return records;
} catch (error) {
console.error('Error fetching resources:', error);
throw error;
}
},
const today = new Date();
const oneMonthFromToday = new Date();
oneMonthFromToday.setMonth(today.getMonth() + 1);
// Get available time slots for a specific booking type and date range
async getAvailableTimeSlots(
bookingTypeId: string,
startDate: Date,
endDate: Date
): Promise<TimeSlot[]> {
try {
const startDateStr = startDate.toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const todayStr = today.toISOString().split('T')[0];
const oneMonthStr = oneMonthFromToday.toISOString().split('T')[0];
const records = await pb.collection('timeSlots').getFullList<TimeSlot>({
filter: `is_active = true && booking_types ~ "${bookingTypeId}" && start_time >= "${startDateStr}" && start_time <= "${endDateStr}"`,
filter: `is_active = true && booking_types ~ "${bookingTypeId}" && start_time >= "${todayStr}" && end_time <= "${oneMonthStr}"`,
sort: 'start_time',
});
return records;
} catch (error) {
console.error('Error fetching time slots:', error);
console.error('Error fetching time slots for booking type:', error);
throw error;
}
},
// Check if a time slot has availability
async checkTimeSlotAvailability(
timeSlotId: string,
startTime: string,
endTime: string,
participantsCount: number
): Promise<{ available: boolean; currentBookings: number }> {
try {
// Get existing confirmed bookings for this time slot
const existingBookings = await pb.collection('bookings').getFullList<Booking>({
filter: `status = "confirmed" && start_time >= "${startTime}" && end_time <= "${endTime}"`,
});
// Generate available time slots grouped by date
generateAvailableTimeSlots(timeSlots: TimeSlot[]): { [date: string]: { start_time: string; end_time: string }[] } {
const today = new Date();
const oneMonthFromToday = new Date();
oneMonthFromToday.setMonth(today.getMonth() + 1);
// Get the time slot to check max capacity
const timeSlot = await pb.collection('timeSlots').getOne<TimeSlot>(timeSlotId);
// Step 1: Generate massive array of individual time slots with specific dates
const allTimeSlots: { start_time: string; end_time: string }[] = [];
const currentBookings = existingBookings.reduce((sum, booking) =>
sum + (booking.participants_count || 0), 0
);
timeSlots.forEach(slot => {
if (!slot.is_reccuring || !slot.recurrence_pattern) {
// Handle non-recurring slots - use as is
allTimeSlots.push({
start_time: slot.start_time,
end_time: slot.end_time
});
return;
}
const availableSpots = timeSlot.max_capacity - currentBookings;
const available = availableSpots >= participantsCount;
// Handle recurring slots - generate dates based on recurrence pattern
const pattern = slot.recurrence_pattern;
const endDate = pattern.end_date ? new Date(pattern.end_date) : oneMonthFromToday;
const finalEndDate = endDate < oneMonthFromToday ? endDate : oneMonthFromToday;
return {
available,
currentBookings,
};
} catch (error) {
console.error('Error checking availability:', error);
throw error;
}
},
// Extract time from original start_time and end_time
const originalStartTime = new Date(slot.start_time);
const originalEndTime = new Date(slot.end_time);
const startHours = originalStartTime.getHours();
const startMinutes = originalStartTime.getMinutes();
const endHours = originalEndTime.getHours();
const endMinutes = originalEndTime.getMinutes();
// Create a new booking
async createBooking(bookingData: {
booking_type: string;
customer_name: string;
customer_email: string;
start_time: string;
end_time: string;
participants_count: number;
internal_notes?: string;
}): Promise<Booking> {
try {
const record = await pb.collection('bookings').create<Booking>({
...bookingData,
status: 'confirmed',
payment_status: 'not_required', // We'll handle payment logic later
payment_required: false,
});
return record;
} catch (error) {
console.error('Error creating booking:', error);
throw error;
}
},
let currentDate = new Date(today);
while (currentDate <= finalEndDate) {
const dayOfWeek = currentDate.getDay();
// Convert Sunday (0) to 6, Monday (1) to 0, etc. to match pattern format
const patternDay = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
if (pattern.days && pattern.days.includes(patternDay)) {
// Create specific datetime for this occurrence
const specificStartTime = new Date(currentDate);
specificStartTime.setHours(startHours, startMinutes, 0, 0);
const specificEndTime = new Date(currentDate);
specificEndTime.setHours(endHours, endMinutes, 0, 0);
allTimeSlots.push({
start_time: specificStartTime.toISOString(),
end_time: specificEndTime.toISOString()
});
}
currentDate.setDate(currentDate.getDate() + 1);
}
});
// Get booking by cancellation token (for cancellation flow)
async getBookingByToken(token: string): Promise<Booking | null> {
try {
const records = await pb.collection('bookings').getFullList<Booking>({
filter: `cancellation_token = "${token}"`,
});
return records[0] || null;
} catch (error) {
console.error('Error fetching booking by token:', error);
return null;
}
},
// Step 2: Group time slots by date (without time)
const availableSlotsByDate: { [date: string]: { start_time: string; end_time: string }[] } = {};
allTimeSlots.forEach(timeSlot => {
const dateStr = timeSlot.start_time.split('T')[0];
if (dateStr) {
if (!availableSlotsByDate[dateStr]) {
availableSlotsByDate[dateStr] = [];
}
availableSlotsByDate[dateStr].push(timeSlot);
}
});
// Cancel booking
async cancelBooking(bookingId: string): Promise<boolean> {
try {
await pb.collection('bookings').update(bookingId, {
status: 'cancelled',
});
return true;
} catch (error) {
console.error('Error cancelling booking:', error);
return false;
}
// Step 3: Sort time slots within each date
Object.keys(availableSlotsByDate).forEach(date => {
availableSlotsByDate[date]?.sort((a, b) => a.start_time.localeCompare(b.start_time));
});
return availableSlotsByDate;
},
};

67
apps/web/messages/en.json Normal file
View File

@@ -0,0 +1,67 @@
{
"bookingForm": {
"title": "Book Your Session",
"subtitle": "Choose your pottery experience",
"loading": "Loading booking options...",
"noBookings": "No bookings available at the moment",
"checkBackLater": "Please check back later",
"selectExperience": "Select Experience",
"chooseDate": "Choose Date",
"availableTimes": "Available Times",
"yourDetails": "Your Details",
"bookingSummary": "Booking Summary",
"fullName": "Full Name",
"fullNameRequired": "Full name is required",
"nameMinLength": "Name must be at least 2 characters",
"emailAddress": "Email Address",
"emailRequired": "Email address is required",
"emailInvalid": "Please enter a valid email address",
"participants": "Number of Participants",
"participant": "participant",
"participantsPlural": "participants",
"participantsMin": "At least 1 participant required",
"participantsMax": "Maximum 8 participants allowed",
"experience": "Experience",
"date": "Date",
"time": "Time",
"total": "Total",
"paymentRequired": "Payment Required",
"continueToPayment": "Continue to Payment",
"confirmBooking": "Confirm Booking",
"perPerson": "per person"
},
"footer": {
"getInTouch": "Get in touch"
},
"language": {
"switchTo": "Switch to Georgian",
"current": "English"
},
"weekdays": {
"short": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
}
},
"months": {
"short": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec"
}
}
}

67
apps/web/messages/ka.json Normal file
View File

@@ -0,0 +1,67 @@
{
"bookingForm": {
"title": "დაჯავშნე შენი სესია",
"subtitle": "აირჩიე ხელოვნების გამოცდილება",
"loading": "იტვირთება ჯავშნის ვარიანტები...",
"noBookings": "ამჟამად ჯავშნები არ არის ხელმისაწვდომი",
"checkBackLater": "გთხოვთ შეამოწმოთ მოგვიანებით",
"selectExperience": "აირჩიე გამოცდილება",
"chooseDate": "აირჩიე თარიღი",
"availableTimes": "ხელმისაწვდომი დრო",
"yourDetails": "შენი დეტალები",
"bookingSummary": "ჯავშნის რეზიუმე",
"fullName": "სრული სახელი",
"fullNameRequired": "სრული სახელი აუცილებელია",
"nameMinLength": "სახელი უნდა იყოს მინიმუმ 2 სიმბოლო",
"emailAddress": "ელ. ფოსტის მისამართი",
"emailRequired": "ელ. ფოსტის მისამართი აუცილებელია",
"emailInvalid": "გთხოვთ შეიყვანოთ სწორი ელ. ფოსტის მისამართი",
"participants": "მონაწილეთა რაოდენობა",
"participant": "მონაწილე",
"participantsPlural": "მონაწილე",
"participantsMin": "საჭიროა მინიმუმ 1 მონაწილე",
"participantsMax": "მაქსიმუმ 8 მონაწილე",
"experience": "გამოცდილება",
"date": "თარიღი",
"time": "დრო",
"total": "სულ",
"paymentRequired": "გადახდა საჭიროა",
"continueToPayment": "გადაიდი გადახდაზე",
"confirmBooking": "დაადასტურე ჯავშანი",
"perPerson": "ერთ ადამიანზე"
},
"footer": {
"getInTouch": "დაგვიკავშირდი"
},
"language": {
"switchTo": "გადაიარე ინგლისურზე",
"current": "ქართული"
},
"weekdays": {
"short": {
"sun": "კვი",
"mon": "ორშ",
"tue": "სამ",
"wed": "ოთხ",
"thu": "ხუთ",
"fri": "პარ",
"sat": "შაბ"
}
},
"months": {
"short": {
"jan": "იან",
"feb": "თებ",
"mar": "მარ",
"apr": "აპრ",
"may": "მაი",
"jun": "ივნ",
"jul": "ივლ",
"aug": "აგვ",
"sep": "სექ",
"oct": "ოქტ",
"nov": "ნოე",
"dec": "დეკ"
}
}
}

22
apps/web/middleware.ts Normal file
View File

@@ -0,0 +1,22 @@
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
// A list of all locales that are supported
locales: ['en', 'ka'],
// Used when no locale matches
defaultLocale: 'en',
// Always redirect to locale-prefixed paths
localePrefix: 'always'
});
export const config = {
// Match all pathnames except for
// - api routes
// - _next (Next.js internal)
// - _vercel (Vercel internal)
// - all files with extensions
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/'],
runtime: 'nodejs'
};

View File

@@ -1,4 +1,8 @@
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./lib/i18n.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
export default withNextIntl(nextConfig);

View File

@@ -15,6 +15,7 @@
"@tailwindcss/postcss": "^4.1.12",
"lucide-react": "^0.542.0",
"next": "^15.5.0",
"next-intl": "^4.3.5",
"pocketbase": "^0.26.2",
"postcss": "^8.5.6",
"react": "^19.1.0",

View File

@@ -1,6 +1,10 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"

View File

@@ -41,11 +41,11 @@ export interface TimeSlot extends BaseRecord {
// Recurrence pattern structure
export interface RecurrencePattern {
type: 'daily' | 'weekly' | 'monthly';
interval: number;
days_of_week?: number[]; // 0-6, Sunday = 0
// type: 'daily' | 'weekly' | 'monthly';
// interval: number;
days?: number[]; // 0-6, Sunday = 0
end_date?: string;
occurrences?: number;
// occurrences?: number;
}
// Booking entity