feat: implement booking feature with service and time slot selection

This commit is contained in:
Danel Kungla
2025-09-03 10:04:00 +03:00
parent a587b222b9
commit f7514c698e
11 changed files with 306 additions and 14 deletions

View File

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

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

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

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

View File

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

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

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

View File

@@ -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 = ({

View File

@@ -25,6 +25,7 @@ async function categoryLoader({
description: category?.description || '',
handle: category?.handle || '',
name: category?.name || '',
products: category?.products || [],
},
};
}

View File

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

View File

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