feat: implement booking feature with service and time slot selection
This commit is contained in:
@@ -2,12 +2,13 @@ import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-he
|
||||
import { loadCategory } from '@/app/home/(user)/_lib/server/load-category';
|
||||
|
||||
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import BookingContainer from '../../../_components/booking/booking-container';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
const title = i18n.t('booking:title');
|
||||
@@ -18,9 +19,13 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
async function BookingHandlePage({ params }: { params: { handle: string } }) {
|
||||
const handle = await params.handle;
|
||||
const { handle } = await params;
|
||||
const { category } = await loadCategory({ handle });
|
||||
|
||||
if (!category) {
|
||||
return <div>Category not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBreadcrumbs
|
||||
@@ -30,10 +35,10 @@ async function BookingHandlePage({ params }: { params: { handle: string } }) {
|
||||
/>
|
||||
<HomeLayoutPageHeader
|
||||
title={<Trans i18nKey={'booking:title'} />}
|
||||
description={<Trans i18nKey={'booking:description'} />}
|
||||
description=""
|
||||
/>
|
||||
|
||||
<PageBody></PageBody>
|
||||
<BookingContainer category={category} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
31
app/home/(user)/_components/booking/booking-container.tsx
Normal file
31
app/home/(user)/_components/booking/booking-container.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Calendar } from '@kit/ui/shadcn/calendar';
|
||||
import { Card } from '@kit/ui/shadcn/card';
|
||||
|
||||
import { ServiceCategory } from '../service-categories';
|
||||
import { BookingProvider } from './booking.provider';
|
||||
import LocationSelector from './location-selector';
|
||||
import ServiceSelector from './service-selector';
|
||||
import TimeSlots from './time-slots';
|
||||
|
||||
const BookingContainer = ({ category }: { category: ServiceCategory }) => {
|
||||
return (
|
||||
<BookingProvider category={category}>
|
||||
<div className="flex flex-row gap-6">
|
||||
<div className="flex flex-col">
|
||||
<ServiceSelector products={category.products} />
|
||||
<Card className="mb-4">
|
||||
<Calendar />
|
||||
</Card>
|
||||
{/* <LocationSelector /> */}
|
||||
</div>
|
||||
<TimeSlots />
|
||||
</div>
|
||||
</BookingProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingContainer;
|
||||
18
app/home/(user)/_components/booking/booking.context.ts
Normal file
18
app/home/(user)/_components/booking/booking.context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import { StoreProduct } from '@medusajs/types';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
const BookingContext = createContext<{
|
||||
timeSlots: string[];
|
||||
selectedService: StoreProduct | null;
|
||||
setSelectedService: (selectedService: any) => void;
|
||||
updateTimeSlots: (serviceId: number) => Promise<void>;
|
||||
}>({
|
||||
timeSlots: [],
|
||||
selectedService: null,
|
||||
setSelectedService: (_) => _,
|
||||
updateTimeSlots: async (_) => noop(),
|
||||
});
|
||||
|
||||
export { BookingContext };
|
||||
47
app/home/(user)/_components/booking/booking.provider.tsx
Normal file
47
app/home/(user)/_components/booking/booking.provider.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { StoreProduct } from '@medusajs/types';
|
||||
|
||||
import { getAvailableAppointmentsForService } from '~/lib/services/connected-online.service';
|
||||
|
||||
import { ServiceCategory } from '../service-categories';
|
||||
import { BookingContext } from './booking.context';
|
||||
|
||||
export function useBooking() {
|
||||
const context = React.useContext(BookingContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useBooking must be used within a BookingProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export const BookingProvider: React.FC<{
|
||||
children: React.ReactElement;
|
||||
category: ServiceCategory;
|
||||
}> = ({ children, category }) => {
|
||||
const [selectedService, setSelectedService] = useState<StoreProduct | null>(
|
||||
category.products[0] || null,
|
||||
);
|
||||
const [timeSlots, setTimeSlots] = useState<string[]>([]);
|
||||
|
||||
const updateTimeSlots = async (serviceId: number) => {
|
||||
const response = await getAvailableAppointmentsForService(serviceId);
|
||||
console.log('updateTimeSlots response', response);
|
||||
// Fetch time slots based on the selected service ID
|
||||
};
|
||||
|
||||
return (
|
||||
<BookingContext.Provider
|
||||
value={{
|
||||
timeSlots,
|
||||
selectedService,
|
||||
setSelectedService,
|
||||
updateTimeSlots,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BookingContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Card } from '@kit/ui/shadcn/card';
|
||||
|
||||
const LocationSelector = () => {
|
||||
return <Card className="p-4">LocationSelector</Card>;
|
||||
};
|
||||
|
||||
export default LocationSelector;
|
||||
83
app/home/(user)/_components/booking/service-selector.tsx
Normal file
83
app/home/(user)/_components/booking/service-selector.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { StoreProduct } from '@medusajs/types';
|
||||
import { ArrowUp, ChevronDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/shadcn/button';
|
||||
import { Card } from '@kit/ui/shadcn/card';
|
||||
import { Label } from '@kit/ui/shadcn/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@kit/ui/shadcn/popover';
|
||||
import { RadioGroup, RadioGroupItem } from '@kit/ui/shadcn/radio-group';
|
||||
|
||||
import { useBooking } from './booking.provider';
|
||||
|
||||
const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
|
||||
const { selectedService, setSelectedService, updateTimeSlots } = useBooking();
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const [firstFourProducts, setFirstFourProducts] = useState<StoreProduct[]>(
|
||||
products.slice(0, 4),
|
||||
);
|
||||
|
||||
const onServiceSelect = async (productId: StoreProduct['id']) => {
|
||||
const product = products.find((p) => p.id === productId);
|
||||
setSelectedService(product);
|
||||
setCollapsed(false);
|
||||
await updateTimeSlots((product!.metadata!.serviceId as number) || 0);
|
||||
};
|
||||
|
||||
console.log('selectedService', selectedService);
|
||||
return (
|
||||
<Card className="mb-4 p-4">
|
||||
<h5 className="text-semibold mb-2">Teenused</h5>
|
||||
<Popover open={collapsed} onOpenChange={setCollapsed}>
|
||||
<div className="flex flex-col">
|
||||
<RadioGroup
|
||||
defaultValue={selectedService?.id || ''}
|
||||
className="mb-2 flex flex-col"
|
||||
onValueChange={onServiceSelect}
|
||||
>
|
||||
{firstFourProducts.map((product) => (
|
||||
<div key={product.id} className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
value={product.id}
|
||||
id={product.id}
|
||||
checked={selectedService?.id === product.id}
|
||||
/>
|
||||
<Label htmlFor={product.id}>{product.title}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
onClick={() => setCollapsed((_) => !_)}
|
||||
className="flex cursor-pointer items-center justify-between border-t py-1"
|
||||
>
|
||||
<span>Kuva kõik</span>
|
||||
<ChevronDown />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent sideOffset={10}>
|
||||
<RadioGroup onValueChange={onServiceSelect}>
|
||||
{products.map((product) => (
|
||||
<div key={product.id + '-2'} className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
value={product.id}
|
||||
id={product.id + '-2'}
|
||||
checked={selectedService?.id === product.id}
|
||||
/>
|
||||
<Label htmlFor={product.id + '-2'}>{product.title}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceSelector;
|
||||
96
app/home/(user)/_components/booking/time-slots.tsx
Normal file
96
app/home/(user)/_components/booking/time-slots.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
|
||||
import { formatCurrency } from '@/packages/shared/src/utils';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@kit/ui/shadcn/button';
|
||||
import { Card } from '@kit/ui/shadcn/card';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AvailableAppointmentsResponse } from '~/lib/types/connected-online';
|
||||
|
||||
const dummyData: AvailableAppointmentsResponse['Data']['T_Booking'] = [
|
||||
{
|
||||
ServiceID: 1,
|
||||
StartTime: new Date('2024-10-10T10:00:00Z'),
|
||||
EndTime: new Date('2024-10-10T11:00:00Z'),
|
||||
HKServiceID: 0,
|
||||
ClinicID: '',
|
||||
LocationID: 0,
|
||||
UserID: 0,
|
||||
SyncUserID: 0,
|
||||
PayorCode: '',
|
||||
},
|
||||
{
|
||||
ServiceID: 1,
|
||||
StartTime: new Date('2024-10-10T11:00:00Z'),
|
||||
EndTime: new Date('2024-10-10T12:00:00Z'),
|
||||
HKServiceID: 0,
|
||||
ClinicID: '',
|
||||
LocationID: 0,
|
||||
UserID: 0,
|
||||
SyncUserID: 0,
|
||||
PayorCode: '',
|
||||
},
|
||||
{
|
||||
ServiceID: 2,
|
||||
StartTime: new Date('2024-10-10T12:00:00Z'),
|
||||
EndTime: new Date('2024-10-10T13:00:00Z'),
|
||||
HKServiceID: 0,
|
||||
ClinicID: '',
|
||||
LocationID: 0,
|
||||
UserID: 0,
|
||||
SyncUserID: 0,
|
||||
PayorCode: '',
|
||||
},
|
||||
];
|
||||
|
||||
const TimeSlots = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{dummyData.map((data) => (
|
||||
<Card
|
||||
className="flex justify-between p-4"
|
||||
key={data.ServiceID + '-time-slot'}
|
||||
>
|
||||
<div>
|
||||
<span>{format(data.StartTime.toString(), 'HH:mm')}</span>
|
||||
<div className="flex">
|
||||
<h5 className="after:mx-2 after:content-['·']">
|
||||
Dr. Jüri Mardikas
|
||||
</h5>
|
||||
<span className="after:mx-2 after:content-['·']">Kardioloog</span>
|
||||
<span>Tervisekassa aeg</span>
|
||||
</div>
|
||||
<div className="flex text-xs">
|
||||
<span className="after:mx-2 after:content-['·']">
|
||||
Ülemiste Tervisemaja 2
|
||||
</span>
|
||||
<span className="after:mx-2 after:content-['·']">
|
||||
Ülemiste füsioteraapiakliinik
|
||||
</span>
|
||||
<span className="after:mx-2 after:content-['·']">
|
||||
Sepapaja 2/1
|
||||
</span>
|
||||
<span>Tallinn</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-end flex items-center justify-center gap-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCurrency({
|
||||
currencyCode: 'EUR',
|
||||
locale: 'et-EE',
|
||||
value: 20,
|
||||
})}
|
||||
</span>
|
||||
<Button>
|
||||
<Trans i18nKey="Broneeri" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeSlots;
|
||||
@@ -4,17 +4,19 @@ import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { createPath, pathsConfig } from '@/packages/shared/src/config';
|
||||
import { pathsConfig } from '@/packages/shared/src/config';
|
||||
import { StoreProduct } from '@medusajs/types';
|
||||
import { ComponentInstanceIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { cn } from '@kit/ui/shadcn';
|
||||
import { Card, CardDescription, CardTitle } from '@kit/ui/shadcn/card';
|
||||
import { Card, CardDescription } from '@kit/ui/shadcn/card';
|
||||
|
||||
export interface ServiceCategory {
|
||||
name: string;
|
||||
handle: string;
|
||||
color: string;
|
||||
description: string;
|
||||
products: StoreProduct[];
|
||||
}
|
||||
|
||||
const ServiceCategories = ({
|
||||
|
||||
@@ -25,6 +25,7 @@ async function categoryLoader({
|
||||
description: category?.description || '',
|
||||
handle: category?.handle || '',
|
||||
name: category?.name || '',
|
||||
products: category?.products || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,12 +51,12 @@ export async function getAvailableAppointmentsForService(
|
||||
: `No booking times present in appointment availability response, service id: ${serviceId}, start time: ${startTime}`;
|
||||
}
|
||||
|
||||
await logRequestResult(
|
||||
ExternalApi.ConnectedOnline,
|
||||
ConnectedOnlineMethodName.GetAvailabilities,
|
||||
RequestStatus.Fail,
|
||||
comment,
|
||||
);
|
||||
// await logRequestResult(
|
||||
// ExternalApi.ConnectedOnline,
|
||||
// ConnectedOnlineMethodName.GetAvailabilities,
|
||||
// RequestStatus.Fail,
|
||||
// comment,
|
||||
// );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -25,12 +25,12 @@ const RadioGroupItem: React.FC<
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
className={cn(
|
||||
'border-primary text-primary focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border shadow-xs focus:outline-hidden focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'border-primary focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border text-white shadow-xs focus:outline-hidden focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<RadioGroupPrimitive.Indicator className="bg-primary flex items-center justify-center rounded-full">
|
||||
<CheckIcon className="fill-primary h-3.5 w-3.5" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
|
||||
Reference in New Issue
Block a user