booking form validation and data types
This commit is contained in:
Binary file not shown.
@@ -8,7 +8,7 @@ export default function Home() {
|
|||||||
</main>
|
</main>
|
||||||
<footer className="bg-white text-black py-8 mt-12">
|
<footer className="bg-white text-black py-8 mt-12">
|
||||||
<div className="max-w-2xl mx-auto px-4 text-center">
|
<div className="max-w-2xl mx-auto px-4 text-center">
|
||||||
<p className="text-gray-600 mb-2">Questions? Get in touch</p>
|
<p className="text-gray-600 mb-2">Get in touch</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:vitrify@asyavee.me"
|
href="mailto:vitrify@asyavee.me"
|
||||||
className="text-blue-600 hover:text-blue-800 transition-colors font-medium"
|
className="text-blue-600 hover:text-blue-800 transition-colors font-medium"
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Calendar, Clock, User, Mail, Users } from 'lucide-react';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import BookingTypeSelector from './BookingTypeSelector';
|
||||||
|
import DateSelector from './DateSelector';
|
||||||
|
import TimeSlotSelector from './TimeSlotSelector';
|
||||||
|
import CustomerDetails from './CustomerDetails';
|
||||||
|
import BookingSummary from './BookingSummary';
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
participantsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
const BookingInterface = () => {
|
const BookingInterface = () => {
|
||||||
const [selectedDate, setSelectedDate] = useState('');
|
const [selectedDate, setSelectedDate] = useState('');
|
||||||
const [selectedTimeSlot, setSelectedTimeSlot] = useState('');
|
const [selectedTimeSlot, setSelectedTimeSlot] = useState('');
|
||||||
const [selectedBookingType, setSelectedBookingType] = useState('');
|
const [selectedBookingType, setSelectedBookingType] = useState('');
|
||||||
const [customerName, setCustomerName] = useState('');
|
|
||||||
const [customerEmail, setCustomerEmail] = useState('');
|
|
||||||
const [participantsCount, setParticipantsCount] = useState(1);
|
const [participantsCount, setParticipantsCount] = useState(1);
|
||||||
|
|
||||||
|
const { register, handleSubmit, formState: { errors, isValid }, setValue, trigger } = useForm<FormData>({
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
customerName: '',
|
||||||
|
customerEmail: '',
|
||||||
|
participantsCount: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Mock data - will be replaced with API calls
|
// Mock data - will be replaced with API calls
|
||||||
const bookingTypes = [
|
const bookingTypes = [
|
||||||
{ id: '1', name: 'wheel_rental', display_name: 'Wheel Rental', requires_payment: false, price_per_person: 0 },
|
{ id: '1', name: 'wheel_rental', display_name: 'Wheel Rental', requires_payment: false, price_per_person: 0 },
|
||||||
@@ -30,35 +48,44 @@ const BookingInterface = () => {
|
|||||||
|
|
||||||
const selectedBookingTypeData = bookingTypes.find(bt => bt.id === selectedBookingType);
|
const selectedBookingTypeData = bookingTypes.find(bt => bt.id === selectedBookingType);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
useEffect(() => {
|
||||||
console.log({
|
setValue('participantsCount', participantsCount);
|
||||||
selectedBookingType,
|
trigger('participantsCount');
|
||||||
selectedDate,
|
}, [participantsCount, setValue, trigger]);
|
||||||
selectedTimeSlot,
|
|
||||||
customerName,
|
|
||||||
customerEmail,
|
|
||||||
participantsCount
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateCalendarDays = () => {
|
const onSubmit = (data: FormData) => {
|
||||||
const today = new Date();
|
const selectedTimeSlotData = timeSlots.find(slot => slot.id === selectedTimeSlot);
|
||||||
const days = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < 14; i++) {
|
const formData = {
|
||||||
const date = new Date(today);
|
bookingTypeId: selectedBookingType,
|
||||||
date.setDate(today.getDate() + i);
|
customerName: data.customerName,
|
||||||
days.push({
|
customerEmail: data.customerEmail,
|
||||||
date: date.toISOString().split('T')[0],
|
startTime: selectedDate && selectedTimeSlotData
|
||||||
day: date.getDate(),
|
? `${selectedDate}T${selectedTimeSlotData.start_time}:00`
|
||||||
month: date.toLocaleDateString('en', { month: 'short' }),
|
: '',
|
||||||
dayName: date.toLocaleDateString('en', { weekday: 'short' })
|
endTime: selectedDate && selectedTimeSlotData
|
||||||
});
|
? `${selectedDate}T${selectedTimeSlotData.end_time}:00`
|
||||||
}
|
: '',
|
||||||
return days;
|
participantsCount: data.participantsCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarDays = generateCalendarDays();
|
const handleParticipantsCountChange = (count: number) => {
|
||||||
|
setParticipantsCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookingTypeChange = (typeId: string) => {
|
||||||
|
setSelectedBookingType(typeId);
|
||||||
|
setSelectedDate('');
|
||||||
|
setSelectedTimeSlot('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (date: string) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
setSelectedTimeSlot('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-4">
|
<div className="min-h-screen bg-gray-50 p-4">
|
||||||
@@ -71,201 +98,53 @@ const BookingInterface = () => {
|
|||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Booking Type Selection */}
|
{/* Booking Type Selection */}
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
<BookingTypeSelector
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
bookingTypes={bookingTypes}
|
||||||
<Calendar className="w-5 h-5" />
|
selectedBookingType={selectedBookingType}
|
||||||
Select Experience
|
onBookingTypeChange={handleBookingTypeChange}
|
||||||
</h2>
|
/>
|
||||||
<div className="grid gap-3">
|
|
||||||
{bookingTypes.map((type) => (
|
|
||||||
<label key={type.id} className="cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="bookingType"
|
|
||||||
value={type.id}
|
|
||||||
checked={selectedBookingType === type.id}
|
|
||||||
onChange={(e) => setSelectedBookingType(e.target.value)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<div className={`p-4 rounded-lg border-2 transition-all ${
|
|
||||||
selectedBookingType === type.id
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}>
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<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
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{type.requires_payment && (
|
|
||||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
|
||||||
Payment Required
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Selection */}
|
{/* Date Selection */}
|
||||||
{selectedBookingType && (
|
{selectedBookingType && (
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
<DateSelector
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
selectedDate={selectedDate}
|
||||||
<Calendar className="w-5 h-5" />
|
onDateChange={handleDateChange}
|
||||||
Choose Date
|
/>
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-7 gap-2 md:gap-3">
|
|
||||||
{calendarDays.map((day) => (
|
|
||||||
<button
|
|
||||||
key={day.date}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedDate(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>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time Slot Selection */}
|
{/* Time Slot Selection */}
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
<TimeSlotSelector
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
timeSlots={timeSlots}
|
||||||
<Clock className="w-5 h-5" />
|
selectedTimeSlot={selectedTimeSlot}
|
||||||
Available Times
|
onTimeSlotChange={setSelectedTimeSlot}
|
||||||
</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={() => setSelectedTimeSlot(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>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Customer Details */}
|
{/* Customer Details */}
|
||||||
{selectedTimeSlot && (
|
{selectedTimeSlot && (
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<CustomerDetails
|
||||||
<User className="w-5 h-5" />
|
register={register}
|
||||||
Your Details
|
errors={errors}
|
||||||
</h2>
|
participantsCount={participantsCount}
|
||||||
|
onParticipantsCountChange={handleParticipantsCountChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Summary & Submit */}
|
||||||
<div>
|
{isValid && (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<div className="mt-8">
|
||||||
Full Name *
|
<BookingSummary
|
||||||
</label>
|
selectedBookingTypeData={selectedBookingTypeData}
|
||||||
<input
|
selectedDate={selectedDate}
|
||||||
type="text"
|
selectedTimeSlot={selectedTimeSlot}
|
||||||
value={customerName}
|
timeSlots={timeSlots}
|
||||||
onChange={(e) => setCustomerName(e.target.value)}
|
participantsCount={participantsCount}
|
||||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
placeholder="Enter your full name"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
</form>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Email Address *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={customerEmail}
|
|
||||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
|
||||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
Number of Participants
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={participantsCount}
|
|
||||||
onChange={(e) => setParticipantsCount(parseInt(e.target.value))}
|
|
||||||
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8].map(num => (
|
|
||||||
<option key={num} value={num}>{num} participant{num > 1 ? 's' : ''}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary & Submit */}
|
|
||||||
{selectedTimeSlot && customerName && customerEmail && (
|
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Summary</h2>
|
|
||||||
|
|
||||||
<div className="space-y-3 mb-6">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Experience:</span>
|
|
||||||
<span className="font-medium">{selectedBookingTypeData?.display_name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Date:</span>
|
|
||||||
<span className="font-medium">{selectedDate}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Time:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{timeSlots.find(s => s.id === selectedTimeSlot)?.start_time} -
|
|
||||||
{timeSlots.find(s => s.id === selectedTimeSlot)?.end_time}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Participants:</span>
|
|
||||||
<span className="font-medium">{participantsCount}</span>
|
|
||||||
</div>
|
|
||||||
{selectedBookingTypeData?.requires_payment && (
|
|
||||||
<div className="flex justify-between text-lg font-semibold border-t pt-3">
|
|
||||||
<span>Total:</span>
|
|
||||||
<span>${selectedBookingTypeData.price_per_person * participantsCount}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="w-full bg-blue-600 text-white py-4 px-6 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
{selectedBookingTypeData?.requires_payment ? 'Continue to Payment' : 'Confirm Booking'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
78
apps/web/components/BookingSummary.tsx
Normal file
78
apps/web/components/BookingSummary.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingSummaryProps {
|
||||||
|
selectedBookingTypeData: BookingType | undefined;
|
||||||
|
selectedDate: string;
|
||||||
|
selectedTimeSlot: string;
|
||||||
|
timeSlots: TimeSlot[];
|
||||||
|
participantsCount: number;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookingSummary: React.FC<BookingSummaryProps> = ({
|
||||||
|
selectedBookingTypeData,
|
||||||
|
selectedDate,
|
||||||
|
selectedTimeSlot,
|
||||||
|
timeSlots,
|
||||||
|
participantsCount,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const selectedTimeSlotData = timeSlots.find(s => s.id === selectedTimeSlot);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Experience:</span>
|
||||||
|
<span className="font-medium">{selectedBookingTypeData?.display_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Date:</span>
|
||||||
|
<span className="font-medium">{selectedDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Time:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{selectedTimeSlotData?.start_time} - {selectedTimeSlotData?.end_time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Participants:</span>
|
||||||
|
<span className="font-medium">{participantsCount}</span>
|
||||||
|
</div>
|
||||||
|
{selectedBookingTypeData?.requires_payment && (
|
||||||
|
<div className="flex justify-between text-lg font-semibold border-t pt-3">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span>₾{selectedBookingTypeData.price_per_person * participantsCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="w-full bg-blue-600 text-white py-4 px-6 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
{selectedBookingTypeData?.requires_payment ? 'Continue to Payment' : 'Confirm Booking'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BookingSummary;
|
||||||
68
apps/web/components/BookingTypeSelector.tsx
Normal file
68
apps/web/components/BookingTypeSelector.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingTypeSelectorProps {
|
||||||
|
bookingTypes: BookingType[];
|
||||||
|
selectedBookingType: string;
|
||||||
|
onBookingTypeChange: (typeId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookingTypeSelector: React.FC<BookingTypeSelectorProps> = ({
|
||||||
|
bookingTypes,
|
||||||
|
selectedBookingType,
|
||||||
|
onBookingTypeChange,
|
||||||
|
}) => {
|
||||||
|
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
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{bookingTypes.map((type) => (
|
||||||
|
<label key={type.id} className="cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="bookingType"
|
||||||
|
value={type.id}
|
||||||
|
checked={selectedBookingType === type.id}
|
||||||
|
onChange={(e) => onBookingTypeChange(e.target.value)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className={`p-4 rounded-lg border-2 transition-all ${
|
||||||
|
selectedBookingType === type.id
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<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
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{type.requires_payment && (
|
||||||
|
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||||
|
Payment Required
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BookingTypeSelector;
|
||||||
116
apps/web/components/CustomerDetails.tsx
Normal file
116
apps/web/components/CustomerDetails.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { User, Users } from 'lucide-react';
|
||||||
|
import { UseFormRegister, FieldErrors } from 'react-hook-form';
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
participantsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomerDetailsProps {
|
||||||
|
register: UseFormRegister<FormData>;
|
||||||
|
errors: FieldErrors<FormData>;
|
||||||
|
participantsCount: number;
|
||||||
|
onParticipantsCountChange: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomerDetails: React.FC<CustomerDetailsProps> = ({
|
||||||
|
register,
|
||||||
|
errors,
|
||||||
|
participantsCount,
|
||||||
|
onParticipantsCountChange,
|
||||||
|
}) => {
|
||||||
|
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">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
Your Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Full Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('customerName', {
|
||||||
|
required: 'Full name is required',
|
||||||
|
minLength: { value: 2, message: 'Name must be at least 2 characters' }
|
||||||
|
})}
|
||||||
|
className={`w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.customerName ? 'border-red-300' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter your full name"
|
||||||
|
/>
|
||||||
|
{errors.customerName && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.customerName.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email Address *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
{...register('customerEmail', {
|
||||||
|
required: 'Email address is required',
|
||||||
|
pattern: {
|
||||||
|
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||||
|
message: 'Please enter a valid email address'
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
className={`w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors.customerEmail ? 'border-red-300' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
{errors.customerEmail && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{errors.customerEmail.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
Number of Participants
|
||||||
|
</label>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<span className="text-xl font-medium">−</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 text-center px-4">
|
||||||
|
<span className="text-xl font-semibold text-gray-900">{participantsCount}</span>
|
||||||
|
<div className="text-xs text-gray-500">participant{participantsCount > 1 ? 's' : ''}</div>
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
<span className="text-xl font-medium">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('participantsCount', {
|
||||||
|
min: { value: 1, message: 'At least 1 participant required' },
|
||||||
|
max: { value: 8, message: 'Maximum 8 participants allowed' }
|
||||||
|
})}
|
||||||
|
value={participantsCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomerDetails;
|
||||||
67
apps/web/components/DateSelector.tsx
Normal file
67
apps/web/components/DateSelector.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Calendar } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CalendarDay {
|
||||||
|
date: string;
|
||||||
|
day: number;
|
||||||
|
month: string;
|
||||||
|
dayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DateSelectorProps {
|
||||||
|
selectedDate: string;
|
||||||
|
onDateChange: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateSelector: React.FC<DateSelectorProps> = ({
|
||||||
|
selectedDate,
|
||||||
|
onDateChange,
|
||||||
|
}) => {
|
||||||
|
const generateCalendarDays = (): CalendarDay[] => {
|
||||||
|
const today = new Date();
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() + i);
|
||||||
|
days.push({
|
||||||
|
date: date.toISOString().split('T')[0],
|
||||||
|
day: date.getDate(),
|
||||||
|
month: date.toLocaleDateString('en', { month: 'short' }),
|
||||||
|
dayName: date.toLocaleDateString('en', { weekday: 'short' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarDays = generateCalendarDays();
|
||||||
|
|
||||||
|
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" />
|
||||||
|
Choose Date
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateSelector;
|
||||||
53
apps/web/components/TimeSlotSelector.tsx
Normal file
53
apps/web/components/TimeSlotSelector.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TimeSlot {
|
||||||
|
id: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeSlotSelectorProps {
|
||||||
|
timeSlots: TimeSlot[];
|
||||||
|
selectedTimeSlot: string;
|
||||||
|
onTimeSlotChange: (slotId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimeSlotSelector: React.FC<TimeSlotSelectorProps> = ({
|
||||||
|
timeSlots,
|
||||||
|
selectedTimeSlot,
|
||||||
|
onTimeSlotChange,
|
||||||
|
}) => {
|
||||||
|
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
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimeSlotSelector;
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
"tailwindcss": "^4.1.12"
|
"tailwindcss": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,79 +1,186 @@
|
|||||||
// types/booking.ts
|
// Base types for PocketBase records
|
||||||
|
export interface BaseRecord {
|
||||||
export interface BookingType {
|
id: string;
|
||||||
id: string;
|
created: string;
|
||||||
name: string;
|
updated: string;
|
||||||
display_name: string;
|
|
||||||
requires_payment: boolean;
|
|
||||||
default_duration: number; // in minutes
|
|
||||||
description?: string;
|
|
||||||
resources: string; // relation to resources collection
|
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Resource {
|
// Resource entity
|
||||||
id: string;
|
export interface Resource extends BaseRecord {
|
||||||
type: 'wheel' | 'workstation';
|
name: string;
|
||||||
capacity: number;
|
type: 'wheel' | 'workstation';
|
||||||
is_active: boolean;
|
capacity: number;
|
||||||
created: string;
|
is_active: boolean;
|
||||||
updated: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeSlot {
|
// Booking Type entity
|
||||||
id: string;
|
export interface BookingType extends BaseRecord {
|
||||||
booking_types: string[]; // relation to booking types
|
name: string;
|
||||||
start_time: string;
|
display_name: string;
|
||||||
end_time: string;
|
description?: string;
|
||||||
is_reccuring: boolean;
|
requires_payment?: boolean;
|
||||||
max_capacity: number;
|
base_duration: number; // minutes, min 30
|
||||||
is_active: boolean;
|
min_duration: number;
|
||||||
recurrence_pattern?: {
|
price_per_person: number;
|
||||||
type: string;
|
resources: string; // relation to Resource
|
||||||
days: number[];
|
min_capacity: number;
|
||||||
end_date: string;
|
max_capacity: number;
|
||||||
};
|
is_active: boolean;
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Booking {
|
// Time Slot entity
|
||||||
id: string;
|
export interface TimeSlot extends BaseRecord {
|
||||||
booking_type: string; // relation to booking type
|
booking_types: string[]; // relation to BookingType (many-to-many)
|
||||||
customer_name: string;
|
start_time: string;
|
||||||
customer_email: string;
|
end_time: string;
|
||||||
internal_notes?: string;
|
is_active: boolean;
|
||||||
start_time: string;
|
is_reccuring?: boolean;
|
||||||
end_time: string;
|
recurrence_pattern?: RecurrencePattern;
|
||||||
participants_count: number;
|
max_capacity: number;
|
||||||
status: 'confirmed' | 'cancelled' | 'completed';
|
|
||||||
payment_status: 'not_required' | 'paid' | 'refunded' | 'partially_refunded' | 'pending';
|
|
||||||
payment_required: boolean;
|
|
||||||
cancellation_token: string;
|
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurrence pattern structure
|
||||||
|
export interface RecurrencePattern {
|
||||||
|
type: 'daily' | 'weekly' | 'monthly';
|
||||||
|
interval: number;
|
||||||
|
days_of_week?: number[]; // 0-6, Sunday = 0
|
||||||
|
end_date?: string;
|
||||||
|
occurrences?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booking entity
|
||||||
|
export interface Booking extends BaseRecord {
|
||||||
|
booking_type: string; // relation to BookingType
|
||||||
|
customer_name: string;
|
||||||
|
customer_email: string;
|
||||||
|
internal_notes?: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
participants_count: number;
|
||||||
|
status: 'confirmed' | 'cancelled' | 'completed';
|
||||||
|
payment_status: 'not_required' | 'paid' | 'refunded' | 'partially_refunded' | 'pending';
|
||||||
|
payment_required: boolean;
|
||||||
|
cancellation_token: string;
|
||||||
|
use_subscription?: string; // relation to subscription (future feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend form types
|
||||||
export interface BookingFormData {
|
export interface BookingFormData {
|
||||||
bookingTypeId: string;
|
bookingTypeId: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerEmail: string;
|
customerEmail: string;
|
||||||
participantsCount: number;
|
startTime: string;
|
||||||
startTime: Date;
|
endTime: string;
|
||||||
endTime: Date;
|
participantsCount: number;
|
||||||
|
internalNotes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For the booking flow state
|
// API response types
|
||||||
export interface BookingFlowState {
|
export interface BookingResponse {
|
||||||
step: 'booking-type' | 'date-time' | 'customer-details' | 'confirmation';
|
success: boolean;
|
||||||
selectedBookingType?: BookingType;
|
booking?: Booking;
|
||||||
selectedDate?: Date;
|
error?: string;
|
||||||
selectedTimeSlot?: string; // time slot ID or time string
|
cancellationUrl?: string;
|
||||||
customerDetails?: {
|
}
|
||||||
name: string;
|
|
||||||
email: string;
|
export interface AvailabilityResponse {
|
||||||
participantsCount: number;
|
success: boolean;
|
||||||
notes?: string;
|
availableSlots?: TimeSlot[];
|
||||||
};
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expanded types with relations
|
||||||
|
export interface BookingWithRelations extends Omit<Booking, 'booking_type'> {
|
||||||
|
expand?: {
|
||||||
|
booking_type?: BookingType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingTypeWithRelations extends Omit<BookingType, 'resources'> {
|
||||||
|
expand?: {
|
||||||
|
resources?: Resource;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSlotWithRelations extends Omit<TimeSlot, 'booking_types'> {
|
||||||
|
expand?: {
|
||||||
|
booking_types?: BookingType[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility types for API operations
|
||||||
|
export interface CreateBookingData {
|
||||||
|
booking_type: string;
|
||||||
|
customer_name: string;
|
||||||
|
customer_email: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
participants_count: number;
|
||||||
|
internal_notes?: string;
|
||||||
|
status: 'confirmed';
|
||||||
|
payment_status: 'not_required' | 'pending';
|
||||||
|
payment_required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBookingData {
|
||||||
|
status?: 'confirmed' | 'cancelled' | 'completed';
|
||||||
|
payment_status?: 'not_required' | 'paid' | 'refunded' | 'partially_refunded' | 'pending';
|
||||||
|
internal_notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and query types
|
||||||
|
export interface BookingFilters {
|
||||||
|
status?: string;
|
||||||
|
payment_status?: string;
|
||||||
|
booking_type?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
customer_email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSlotFilters {
|
||||||
|
booking_type?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error types
|
||||||
|
export interface BookingError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation types
|
||||||
|
export interface BookingValidation {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: BookingError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export const BOOKING_TYPES = {
|
||||||
|
WHEEL_RENTAL: 'wheel_rental',
|
||||||
|
HAND_BUILDING: 'hand_building_coworking',
|
||||||
|
PERSONAL_WORKSHOP: 'personal_workshop',
|
||||||
|
GROUP_WORKSHOP: 'group_workshop',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BOOKING_STATUS = {
|
||||||
|
CONFIRMED: 'confirmed',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PAYMENT_STATUS = {
|
||||||
|
NOT_REQUIRED: 'not_required',
|
||||||
|
PAID: 'paid',
|
||||||
|
REFUNDED: 'refunded',
|
||||||
|
PARTIALLY_REFUNDED: 'partially_refunded',
|
||||||
|
PENDING: 'pending',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const RESOURCE_TYPES = {
|
||||||
|
WHEEL: 'wheel',
|
||||||
|
WORKSTATION: 'workstation',
|
||||||
|
} as const;
|
||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -83,6 +83,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.1.0(react@19.1.0)
|
version: 19.1.0(react@19.1.0)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.62.0
|
||||||
|
version: 7.62.0(react@19.1.0)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.12
|
specifier: ^4.1.12
|
||||||
version: 4.1.12
|
version: 4.1.12
|
||||||
@@ -1437,6 +1440,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.1.0
|
react: ^19.1.0
|
||||||
|
|
||||||
|
react-hook-form@7.62.0:
|
||||||
|
resolution: {integrity: sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -3063,6 +3072,10 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
scheduler: 0.26.0
|
scheduler: 0.26.0
|
||||||
|
|
||||||
|
react-hook-form@7.62.0(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react@19.1.0: {}
|
react@19.1.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user