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 { loadCategory } from '@/app/home/(user)/_lib/server/load-category';
|
||||||
|
|
||||||
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
|
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
|
||||||
import { PageBody } from '@kit/ui/page';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import BookingContainer from '../../../_components/booking/booking-container';
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
const i18n = await createI18nServerInstance();
|
const i18n = await createI18nServerInstance();
|
||||||
const title = i18n.t('booking:title');
|
const title = i18n.t('booking:title');
|
||||||
@@ -18,9 +19,13 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function BookingHandlePage({ params }: { params: { handle: string } }) {
|
async function BookingHandlePage({ params }: { params: { handle: string } }) {
|
||||||
const handle = await params.handle;
|
const { handle } = await params;
|
||||||
const { category } = await loadCategory({ handle });
|
const { category } = await loadCategory({ handle });
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return <div>Category not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBreadcrumbs
|
<AppBreadcrumbs
|
||||||
@@ -30,10 +35,10 @@ async function BookingHandlePage({ params }: { params: { handle: string } }) {
|
|||||||
/>
|
/>
|
||||||
<HomeLayoutPageHeader
|
<HomeLayoutPageHeader
|
||||||
title={<Trans i18nKey={'booking:title'} />}
|
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 { 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 { ComponentInstanceIcon } from '@radix-ui/react-icons';
|
||||||
|
|
||||||
import { cn } from '@kit/ui/shadcn';
|
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 {
|
export interface ServiceCategory {
|
||||||
name: string;
|
name: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
color: string;
|
color: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
products: StoreProduct[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServiceCategories = ({
|
const ServiceCategories = ({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ async function categoryLoader({
|
|||||||
description: category?.description || '',
|
description: category?.description || '',
|
||||||
handle: category?.handle || '',
|
handle: category?.handle || '',
|
||||||
name: category?.name || '',
|
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}`;
|
: `No booking times present in appointment availability response, service id: ${serviceId}, start time: ${startTime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await logRequestResult(
|
// await logRequestResult(
|
||||||
ExternalApi.ConnectedOnline,
|
// ExternalApi.ConnectedOnline,
|
||||||
ConnectedOnlineMethodName.GetAvailabilities,
|
// ConnectedOnlineMethodName.GetAvailabilities,
|
||||||
RequestStatus.Fail,
|
// RequestStatus.Fail,
|
||||||
comment,
|
// comment,
|
||||||
);
|
// );
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ const RadioGroupItem: React.FC<
|
|||||||
return (
|
return (
|
||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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" />
|
<CheckIcon className="fill-primary h-3.5 w-3.5" />
|
||||||
</RadioGroupPrimitive.Indicator>
|
</RadioGroupPrimitive.Indicator>
|
||||||
</RadioGroupPrimitive.Item>
|
</RadioGroupPrimitive.Item>
|
||||||
|
|||||||
Reference in New Issue
Block a user