timeslots API
This commit is contained in:
40
apps/web/app/[locale]/layout.tsx
Normal file
40
apps/web/app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/web/app/[locale]/page.tsx
Normal file
27
apps/web/app/[locale]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
15
apps/web/lib/bookingService.ts
Normal file
15
apps/web/lib/bookingService.ts
Normal 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
16
apps/web/lib/i18n.ts
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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
67
apps/web/messages/en.json
Normal 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
67
apps/web/messages/ka.json
Normal 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
22
apps/web/middleware.ts
Normal 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'
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user