add cart functionality for tto services
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import { Tables } from '@kit/supabase/database';
|
|
||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
import { logSyncResult } from '~/lib/services/audit.service';
|
import { logSyncResult } from '~/lib/services/audit.service';
|
||||||
@@ -131,7 +130,7 @@ export default async function syncConnectedOnline() {
|
|||||||
return {
|
return {
|
||||||
id: service.ID,
|
id: service.ID,
|
||||||
clinic_id: service.ClinicID,
|
clinic_id: service.ClinicID,
|
||||||
sync_id: service.SyncID,
|
sync_id: Number(service.SyncID),
|
||||||
name: service.Name,
|
name: service.Name,
|
||||||
description: service.Description || null,
|
description: service.Description || null,
|
||||||
price: service.Price,
|
price: service.Price,
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'
|
|||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import {
|
import {
|
||||||
bookAppointment,
|
bookAppointment,
|
||||||
getOrderedTtoServices,
|
|
||||||
} from '~/lib/services/connected-online.service';
|
} from '~/lib/services/connected-online.service';
|
||||||
import { FailureReason } from '~/lib/types/connected-online';
|
import { FailureReason } from '~/lib/types/connected-online';
|
||||||
import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service';
|
import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service';
|
||||||
import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service';
|
import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service';
|
||||||
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
|
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
|
||||||
import { AccountWithParams } from '@kit/accounts/types/accounts';
|
import { AccountWithParams } from '@kit/accounts/types/accounts';
|
||||||
|
import { getOrderedTtoServices } from '~/lib/services/reservation.service';
|
||||||
|
|
||||||
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
|
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
|
||||||
const ANALYSIS_TYPE_HANDLE = 'synlab-analysis';
|
const ANALYSIS_TYPE_HANDLE = 'synlab-analysis';
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { listProductTypes } from '@lib/data/products';
|
|||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
import { findProductTypeIdByHandle } from '~/lib/utils';
|
import { findProductTypeIdByHandle } from '~/lib/utils';
|
||||||
|
|
||||||
|
import { getCartReservations } from '~/lib/services/reservation.service';
|
||||||
import Cart from '../../_components/cart';
|
import Cart from '../../_components/cart';
|
||||||
import CartTimer from '../../_components/cart/cart-timer';
|
import CartTimer from '../../_components/cart/cart-timer';
|
||||||
|
import { EnrichedCartItem } from '../../_components/cart/types';
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await createI18nServerInstance();
|
const { t } = await createI18nServerInstance();
|
||||||
@@ -37,10 +39,6 @@ async function CartPage() {
|
|||||||
productTypes,
|
productTypes,
|
||||||
'analysis-packages',
|
'analysis-packages',
|
||||||
);
|
);
|
||||||
const ttoServiceTypeId = findProductTypeIdByHandle(
|
|
||||||
productTypes,
|
|
||||||
'tto-service',
|
|
||||||
);
|
|
||||||
|
|
||||||
const synlabAnalyses =
|
const synlabAnalyses =
|
||||||
analysisPackagesTypeId && synlabAnalysisTypeId && cart?.items
|
analysisPackagesTypeId && synlabAnalysisTypeId && cart?.items
|
||||||
@@ -54,14 +52,11 @@ async function CartPage() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const ttoServiceItems =
|
|
||||||
ttoServiceTypeId && cart?.items
|
|
||||||
? cart?.items?.filter((item) => {
|
|
||||||
const productTypeId = item.product?.type_id;
|
|
||||||
return productTypeId && productTypeId === ttoServiceTypeId;
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
|
let ttoServiceItems: EnrichedCartItem[] = [];
|
||||||
|
if (cart?.items?.length) {
|
||||||
|
ttoServiceItems = await getCartReservations(cart);
|
||||||
|
}
|
||||||
const otherItemsSorted = ttoServiceItems.sort((a, b) =>
|
const otherItemsSorted = ttoServiceItems.sort((a, b) =>
|
||||||
(a.updated_at ?? '') > (b.updated_at ?? '') ? -1 : 1,
|
(a.updated_at ?? '') > (b.updated_at ?? '') ? -1 : 1,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,46 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ServiceCategory } from '../service-categories';
|
import { StoreProduct } from '@medusajs/types';
|
||||||
|
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { EnrichedCartItem } from '../cart/types';
|
||||||
import BookingCalendar from './booking-calendar';
|
import BookingCalendar from './booking-calendar';
|
||||||
import { BookingProvider } from './booking.provider';
|
import { BookingProvider } from './booking.provider';
|
||||||
import LocationSelector from './location-selector';
|
import LocationSelector from './location-selector';
|
||||||
import ServiceSelector from './service-selector';
|
import ServiceSelector from './service-selector';
|
||||||
import TimeSlots from './time-slots';
|
import TimeSlots from './time-slots';
|
||||||
|
|
||||||
const BookingContainer = ({ category }: { category: ServiceCategory }) => {
|
const BookingContainer = ({
|
||||||
|
category,
|
||||||
|
cartItem,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
category: { products: StoreProduct[]; countryCode: string };
|
||||||
|
cartItem?: EnrichedCartItem;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}) => {
|
||||||
|
const products = cartItem?.product ? [cartItem.product] : category.products;
|
||||||
|
|
||||||
|
if (!cartItem || !products?.length) {
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="booking:noProducts" />
|
||||||
|
</p>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BookingProvider category={category}>
|
<BookingProvider category={{ products }} service={cartItem?.product}>
|
||||||
<div className="xs:flex-row flex max-h-full flex-col gap-6">
|
<div className="xs:flex-row flex max-h-full flex-col gap-6">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<ServiceSelector products={category.products} />
|
<ServiceSelector products={products} />
|
||||||
<BookingCalendar />
|
<BookingCalendar />
|
||||||
<LocationSelector />
|
<LocationSelector />
|
||||||
</div>
|
</div>
|
||||||
<TimeSlots countryCode={category.countryCode} />
|
<TimeSlots
|
||||||
|
countryCode={category.countryCode}
|
||||||
|
cartItem={cartItem}
|
||||||
|
onComplete={onComplete}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BookingProvider>
|
</BookingProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { StoreProduct } from '@medusajs/types';
|
|||||||
|
|
||||||
import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service';
|
import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service';
|
||||||
|
|
||||||
import { ServiceCategory } from '../service-categories';
|
|
||||||
import { BookingContext, Location, TimeSlot } from './booking.context';
|
import { BookingContext, Location, TimeSlot } from './booking.context';
|
||||||
|
|
||||||
export function useBooking() {
|
export function useBooking() {
|
||||||
@@ -19,10 +18,11 @@ export function useBooking() {
|
|||||||
|
|
||||||
export const BookingProvider: React.FC<{
|
export const BookingProvider: React.FC<{
|
||||||
children: React.ReactElement;
|
children: React.ReactElement;
|
||||||
category: ServiceCategory;
|
category: { products: StoreProduct[] };
|
||||||
}> = ({ children, category }) => {
|
service?: StoreProduct;
|
||||||
|
}> = ({ children, category, service }) => {
|
||||||
const [selectedService, setSelectedService] = useState<StoreProduct | null>(
|
const [selectedService, setSelectedService] = useState<StoreProduct | null>(
|
||||||
category.products[0] || null,
|
(service ?? category?.products?.[0]) || null,
|
||||||
);
|
);
|
||||||
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(
|
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
@@ -32,6 +32,7 @@ export const BookingProvider: React.FC<{
|
|||||||
const [locations, setLocations] = useState<Location[] | null>(null);
|
const [locations, setLocations] = useState<Location[] | null>(null);
|
||||||
const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false);
|
const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let metadataServiceIds = [];
|
let metadataServiceIds = [];
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { useBooking } from './booking.provider';
|
|||||||
const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
|
const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
|
||||||
const { selectedService, setSelectedService } = useBooking();
|
const { selectedService, setSelectedService } = useBooking();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [firstFourProducts] = useState<StoreProduct[]>(products.slice(0, 4));
|
const [firstFourProducts] = useState<StoreProduct[]>(products?.slice(0, 4));
|
||||||
|
|
||||||
const onServiceSelect = async (productId: StoreProduct['id']) => {
|
const onServiceSelect = async (productId: StoreProduct['id']) => {
|
||||||
const product = products.find((p) => p.id === productId);
|
const product = products.find((p) => p.id === productId);
|
||||||
@@ -38,7 +38,7 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
|
|||||||
className="mb-2 flex flex-col"
|
className="mb-2 flex flex-col"
|
||||||
onValueChange={onServiceSelect}
|
onValueChange={onServiceSelect}
|
||||||
>
|
>
|
||||||
{firstFourProducts.map((product) => (
|
{firstFourProducts?.map((product) => (
|
||||||
<div key={product.id} className="flex items-center gap-2">
|
<div key={product.id} className="flex items-center gap-2">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
value={product.id}
|
value={product.id}
|
||||||
@@ -49,21 +49,23 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<PopoverTrigger asChild>
|
{products.length > 4 && (
|
||||||
<div
|
<PopoverTrigger asChild>
|
||||||
onClick={() => setCollapsed((_) => !_)}
|
<div
|
||||||
className="flex cursor-pointer items-center justify-between border-t py-1"
|
onClick={() => setCollapsed((_) => !_)}
|
||||||
>
|
className="flex cursor-pointer items-center justify-between border-t py-1"
|
||||||
<span>
|
>
|
||||||
<Trans i18nKey="booking:showAll" />
|
<span>
|
||||||
</span>
|
<Trans i18nKey="booking:showAll" />
|
||||||
<ChevronDown />
|
</span>
|
||||||
</div>
|
<ChevronDown />
|
||||||
</PopoverTrigger>
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PopoverContent sideOffset={10}>
|
<PopoverContent sideOffset={10}>
|
||||||
<RadioGroup onValueChange={onServiceSelect}>
|
<RadioGroup onValueChange={onServiceSelect}>
|
||||||
{products.map((product) => (
|
{products?.map((product) => (
|
||||||
<div key={product.id + '-2'} className="flex items-center gap-2">
|
<div key={product.id + '-2'} className="flex items-center gap-2">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
value={product.id}
|
value={product.id}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { toast } from '@kit/ui/sonner';
|
|||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
import { cn } from '@kit/ui/utils';
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
import { updateReservationTime } from '~/lib/services/reservation.service';
|
||||||
|
|
||||||
import { createInitialReservationAction } from '../../_lib/server/actions';
|
import { createInitialReservationAction } from '../../_lib/server/actions';
|
||||||
|
import { EnrichedCartItem } from '../cart/types';
|
||||||
import { ServiceProvider, TimeSlot } from './booking.context';
|
import { ServiceProvider, TimeSlot } from './booking.context';
|
||||||
import { useBooking } from './booking.provider';
|
import { useBooking } from './booking.provider';
|
||||||
|
|
||||||
@@ -32,7 +35,15 @@ const getServiceProviderTitle = (
|
|||||||
|
|
||||||
const PAGE_SIZE = 7;
|
const PAGE_SIZE = 7;
|
||||||
|
|
||||||
const TimeSlots = ({ countryCode }: { countryCode: string }) => {
|
const TimeSlots = ({
|
||||||
|
countryCode,
|
||||||
|
cartItem,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
countryCode: string;
|
||||||
|
cartItem?: EnrichedCartItem;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}) => {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -133,6 +144,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
|
|||||||
booking.selectedLocationId ? booking.selectedLocationId : null,
|
booking.selectedLocationId ? booking.selectedLocationId : null,
|
||||||
comments,
|
comments,
|
||||||
).then(() => {
|
).then(() => {
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
router.push(pathsConfig.app.cart);
|
router.push(pathsConfig.app.cart);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,6 +157,49 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangeTime = async (
|
||||||
|
timeSlot: TimeSlot,
|
||||||
|
reservationId: number,
|
||||||
|
cartId: string,
|
||||||
|
) => {
|
||||||
|
const syncedService = timeSlot.syncedService;
|
||||||
|
if (!syncedService) {
|
||||||
|
return toast.error(t('booking:serviceNotFound'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookTimePromise = updateReservationTime(
|
||||||
|
reservationId,
|
||||||
|
timeSlot.StartTime,
|
||||||
|
Number(syncedService.id),
|
||||||
|
timeSlot.UserID,
|
||||||
|
timeSlot.SyncUserID,
|
||||||
|
booking.selectedLocationId ? booking.selectedLocationId : null,
|
||||||
|
cartId,
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.promise(() => bookTimePromise, {
|
||||||
|
success: <Trans i18nKey={'booking:bookTimeSuccess'} />,
|
||||||
|
error: <Trans i18nKey={'booking:bookTimeError'} />,
|
||||||
|
loading: <Trans i18nKey={'booking:bookTimeLoading'} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeSelect = async (timeSlot: TimeSlot) => {
|
||||||
|
if (cartItem?.reservation.id) {
|
||||||
|
return handleChangeTime(
|
||||||
|
timeSlot,
|
||||||
|
cartItem.reservation.id,
|
||||||
|
cartItem.cart_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleBookTime(timeSlot);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<div className="flex h-full w-full flex-col gap-2 overflow-auto">
|
<div className="flex h-full w-full flex-col gap-2 overflow-auto">
|
||||||
@@ -154,9 +211,12 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
|
|||||||
);
|
);
|
||||||
const price =
|
const price =
|
||||||
booking.selectedService?.variants?.[0]?.calculated_price
|
booking.selectedService?.variants?.[0]?.calculated_price
|
||||||
?.calculated_amount;
|
?.calculated_amount ?? cartItem?.unit_price;
|
||||||
return (
|
return (
|
||||||
<Card className="grid w-full xs:flex justify-center-safe gap-3 xs:justify-between p-4" key={index}>
|
<Card
|
||||||
|
className="xs:flex xs:justify-between grid w-full justify-center-safe gap-3 p-4"
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<span>{formatDateAndTime(timeSlot.StartTime.toString())}</span>
|
<span>{formatDateAndTime(timeSlot.StartTime.toString())}</span>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
@@ -177,11 +237,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
|
|||||||
)}
|
)}
|
||||||
{isEHIF && <span>{t('booking:ehifBooking')}</span>}
|
{isEHIF && <span>{t('booking:ehifBooking')}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex text-xs">
|
<div className="flex text-xs">{timeSlot.location?.address}</div>
|
||||||
{timeSlot.location?.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-end flex items-center justify-between not-last:xs:justify-center gap-2">
|
<div className="flex-end not-last:xs:justify-center flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
currencyCode: 'EUR',
|
currencyCode: 'EUR',
|
||||||
@@ -189,7 +247,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
|
|||||||
value: price ?? '',
|
value: price ?? '',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<Button onClick={() => handleBookTime(timeSlot)} size="sm">
|
<Button onClick={() => handleTimeSelect(timeSlot)} size="sm">
|
||||||
<Trans i18nKey="common:book" />
|
<Trans i18nKey="common:book" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
141
app/home/(user)/_components/cart/cart-service-item.tsx
Normal file
141
app/home/(user)/_components/cart/cart-service-item.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { TableCell, TableRow } from '@kit/ui/table';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import BookingContainer from '../booking/booking-container';
|
||||||
|
import CartItemDelete from './cart-item-delete';
|
||||||
|
import { EnrichedCartItem } from './types';
|
||||||
|
|
||||||
|
const EditCartServiceItemModal = ({
|
||||||
|
item,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
item: EnrichedCartItem | null;
|
||||||
|
onComplete: () => void;
|
||||||
|
}) => {
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog defaultOpen>
|
||||||
|
<DialogContent className="xs:max-w-[90vw] flex max-h-screen max-w-full flex-col items-center gap-4 space-y-4 overflow-y-scroll">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans i18nKey="cart:editServiceItem.title" />
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans i18nKey="cart:editServiceItem.description" />
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div>
|
||||||
|
{item.product && item.reservation.countryCode ? (
|
||||||
|
<BookingContainer
|
||||||
|
category={{
|
||||||
|
products: [item.product],
|
||||||
|
countryCode: item.reservation.countryCode,
|
||||||
|
}}
|
||||||
|
cartItem={item}
|
||||||
|
onComplete={onComplete}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="booking:noProducts" />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CartServiceItem({
|
||||||
|
item,
|
||||||
|
currencyCode,
|
||||||
|
isUnavailable,
|
||||||
|
}: {
|
||||||
|
item: EnrichedCartItem;
|
||||||
|
currencyCode: string;
|
||||||
|
isUnavailable?: boolean;
|
||||||
|
}) {
|
||||||
|
const [editingItem, setEditingItem] = useState<EnrichedCartItem | null>(null);
|
||||||
|
const {
|
||||||
|
i18n: { language },
|
||||||
|
} = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow className="w-full" data-testid="product-row">
|
||||||
|
<TableCell className="w-[100%] px-4 text-left sm:px-6">
|
||||||
|
<p
|
||||||
|
className="txt-medium-plus text-ui-fg-base"
|
||||||
|
data-testid="product-title"
|
||||||
|
>
|
||||||
|
{item.product_title}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-4 sm:px-6">
|
||||||
|
{formatDateAndTime(item.reservation.startTime.toString())}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-4 sm:px-6">
|
||||||
|
{item.reservation.location?.address ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-4 sm:px-6">{item.quantity}</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="min-w-[80px] px-4 sm:px-6">
|
||||||
|
{formatCurrency({
|
||||||
|
value: item.unit_price,
|
||||||
|
currencyCode,
|
||||||
|
locale: language,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="min-w-[80px] px-4 text-right sm:px-6">
|
||||||
|
{formatCurrency({
|
||||||
|
value: item.total,
|
||||||
|
currencyCode,
|
||||||
|
locale: language,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-4 text-right sm:px-6">
|
||||||
|
<span className="flex justify-end gap-x-1">
|
||||||
|
<Button size="sm" onClick={() => setEditingItem(item)}>
|
||||||
|
<Trans i18nKey="common:change" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-4 text-right sm:px-6">
|
||||||
|
<span className="flex w-[60px] justify-end gap-x-1">
|
||||||
|
<CartItemDelete id={item.id} />
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isUnavailable && <TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-destructive px-4 text-left sm:px-6">
|
||||||
|
<Trans i18nKey="booking:timeSlotUnavailable" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>}
|
||||||
|
<EditCartServiceItemModal
|
||||||
|
item={editingItem}
|
||||||
|
onComplete={() => setEditingItem(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
app/home/(user)/_components/cart/cart-service-items.tsx
Normal file
72
app/home/(user)/_components/cart/cart-service-items.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { StoreCart } from '@medusajs/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@kit/ui/table';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import CartServiceItem from './cart-service-item';
|
||||||
|
import { EnrichedCartItem } from './types';
|
||||||
|
|
||||||
|
export default function CartServiceItems({
|
||||||
|
cart,
|
||||||
|
items,
|
||||||
|
productColumnLabelKey,
|
||||||
|
unavailableLineItemIds
|
||||||
|
}: {
|
||||||
|
cart: StoreCart;
|
||||||
|
items: EnrichedCartItem[];
|
||||||
|
productColumnLabelKey: string;
|
||||||
|
unavailableLineItemIds?: string[]
|
||||||
|
}) {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table className="border-separate rounded-lg border">
|
||||||
|
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="px-4 sm:px-6">
|
||||||
|
<Trans i18nKey={productColumnLabelKey} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4 sm:px-6">
|
||||||
|
<Trans i18nKey="cart:table.time" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4 sm:px-6">
|
||||||
|
<Trans i18nKey="cart:table.location" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4 sm:px-6">
|
||||||
|
<Trans i18nKey="cart:table.quantity" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="min-w-[100px] px-4 sm:px-6">
|
||||||
|
<Trans i18nKey="cart:table.price" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="min-w-[100px] px-4 text-right sm:px-6">
|
||||||
|
<Trans i18nKey="cart:table.total" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4 sm:px-6"></TableHead>
|
||||||
|
<TableHead className="px-4 sm:px-6"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items
|
||||||
|
.sort((a, b) =>
|
||||||
|
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
|
||||||
|
)
|
||||||
|
.map((item) => (
|
||||||
|
<CartServiceItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
currencyCode={cart.currency_code}
|
||||||
|
isUnavailable={unavailableLineItemIds?.includes(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { handleNavigateToPayment } from '@/lib/services/medusaCart.service';
|
||||||
|
import { formatCurrency } from '@/packages/shared/src/utils';
|
||||||
|
import { initiatePaymentSession } from '@lib/data/cart';
|
||||||
|
import { StoreCart, StoreCartLineItem } from '@medusajs/types';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
|
|
||||||
import CartItems from "./cart-items"
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import { Card, CardContent, CardHeader } from '@kit/ui/card';
|
||||||
Card,
|
import { Trans } from '@kit/ui/trans';
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
import AnalysisLocation from './analysis-location';
|
||||||
} from '@kit/ui/card';
|
import CartItems from './cart-items';
|
||||||
import DiscountCode from "./discount-code";
|
import CartServiceItems from './cart-service-items';
|
||||||
import { initiatePaymentSession } from "@lib/data/cart";
|
import DiscountCode from './discount-code';
|
||||||
import { formatCurrency } from "@/packages/shared/src/utils";
|
import { EnrichedCartItem } from './types';
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
|
|
||||||
import AnalysisLocation from "./analysis-location";
|
|
||||||
|
|
||||||
const IS_DISCOUNT_SHOWN = true as boolean;
|
const IS_DISCOUNT_SHOWN = true as boolean;
|
||||||
|
|
||||||
@@ -25,13 +26,16 @@ export default function Cart({
|
|||||||
synlabAnalyses,
|
synlabAnalyses,
|
||||||
ttoServiceItems,
|
ttoServiceItems,
|
||||||
}: {
|
}: {
|
||||||
cart: StoreCart | null
|
cart: StoreCart | null;
|
||||||
synlabAnalyses: StoreCartLineItem[];
|
synlabAnalyses: StoreCartLineItem[];
|
||||||
ttoServiceItems: StoreCartLineItem[];
|
ttoServiceItems: EnrichedCartItem[];
|
||||||
}) {
|
}) {
|
||||||
const { i18n: { language } } = useTranslation();
|
const {
|
||||||
|
i18n: { language },
|
||||||
|
} = useTranslation();
|
||||||
|
|
||||||
const [isInitiatingSession, setIsInitiatingSession] = useState(false);
|
const [isInitiatingSession, setIsInitiatingSession] = useState(false);
|
||||||
|
const [unavailableLineItemIds, setUnavailableLineItemIds] = useState<string[]>()
|
||||||
|
|
||||||
const items = cart?.items ?? [];
|
const items = cart?.items ?? [];
|
||||||
|
|
||||||
@@ -39,7 +43,10 @@ export default function Cart({
|
|||||||
return (
|
return (
|
||||||
<div className="content-container py-5 lg:px-4">
|
<div className="content-container py-5 lg:px-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col justify-center items-center" data-testid="empty-cart-message">
|
<div
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
data-testid="empty-cart-message"
|
||||||
|
>
|
||||||
<h4 className="text-center">
|
<h4 className="text-center">
|
||||||
<Trans i18nKey="cart:emptyCartMessage" />
|
<Trans i18nKey="cart:emptyCartMessage" />
|
||||||
</h4>
|
</h4>
|
||||||
@@ -60,8 +67,13 @@ export default function Cart({
|
|||||||
if (response.payment_collection) {
|
if (response.payment_collection) {
|
||||||
const { payment_sessions } = response.payment_collection;
|
const { payment_sessions } = response.payment_collection;
|
||||||
const paymentSessionId = payment_sessions![0]!.id;
|
const paymentSessionId = payment_sessions![0]!.id;
|
||||||
const url = await handleNavigateToPayment({ language, paymentSessionId });
|
const result = await handleNavigateToPayment({ language, paymentSessionId });
|
||||||
window.location.href = url;
|
if (result.url) {
|
||||||
|
window.location.href = result.url;
|
||||||
|
}
|
||||||
|
if (result.unavailableLineItemIds) {
|
||||||
|
setUnavailableLineItemIds(result.unavailableLineItemIds)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsInitiatingSession(false);
|
setIsInitiatingSession(false);
|
||||||
}
|
}
|
||||||
@@ -71,21 +83,30 @@ export default function Cart({
|
|||||||
const isLocationsShown = synlabAnalyses.length > 0;
|
const isLocationsShown = synlabAnalyses.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
|
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 lg:px-4">
|
||||||
<div className="flex flex-col bg-white gap-y-6">
|
<div className="flex flex-col gap-y-6 bg-white">
|
||||||
<CartItems cart={cart} items={synlabAnalyses} productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel" />
|
<CartItems
|
||||||
<CartItems cart={cart} items={ttoServiceItems} productColumnLabelKey="cart:items.ttoServices.productColumnLabel" />
|
cart={cart}
|
||||||
|
items={synlabAnalyses}
|
||||||
|
productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel"
|
||||||
|
/>
|
||||||
|
<CartServiceItems
|
||||||
|
cart={cart}
|
||||||
|
items={ttoServiceItems}
|
||||||
|
productColumnLabelKey="cart:items.ttoServices.productColumnLabel"
|
||||||
|
unavailableLineItemIds={unavailableLineItemIds}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{hasCartItems && (
|
{hasCartItems && (
|
||||||
<>
|
<>
|
||||||
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 pt-2 sm:pt-4">
|
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
|
||||||
<div className="w-full sm:w-auto sm:mr-[42px]">
|
<div className="w-full sm:mr-[42px] sm:w-auto">
|
||||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
<p className="text-muted-foreground ml-0 text-sm font-bold">
|
||||||
<Trans i18nKey="cart:order.subtotal" />
|
<Trans i18nKey="cart:order.subtotal" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||||
<p className="text-sm text-right">
|
<p className="text-right text-sm">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
value: cart.subtotal,
|
value: cart.subtotal,
|
||||||
currencyCode: cart.currency_code,
|
currencyCode: cart.currency_code,
|
||||||
@@ -94,14 +115,14 @@ export default function Cart({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 py-2 sm:py-4">
|
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
|
||||||
<div className="w-full sm:w-auto sm:mr-[42px]">
|
<div className="w-full sm:mr-[42px] sm:w-auto">
|
||||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
<p className="text-muted-foreground ml-0 text-sm font-bold">
|
||||||
<Trans i18nKey="cart:order.promotionsTotal" />
|
<Trans i18nKey="cart:order.promotionsTotal" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||||
<p className="text-sm text-right">
|
<p className="text-right text-sm">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
value: cart.discount_total,
|
value: cart.discount_total,
|
||||||
currencyCode: cart.currency_code,
|
currencyCode: cart.currency_code,
|
||||||
@@ -110,14 +131,14 @@ export default function Cart({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6">
|
<div className="flex gap-x-4 px-4 sm:justify-end sm:px-6">
|
||||||
<div className="w-full sm:w-auto sm:mr-[42px]">
|
<div className="w-full sm:mr-[42px] sm:w-auto">
|
||||||
<p className="ml-0 font-bold text-sm">
|
<p className="ml-0 text-sm font-bold">
|
||||||
<Trans i18nKey="cart:order.total" />
|
<Trans i18nKey="cart:order.total" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||||
<p className="text-sm text-right">
|
<p className="text-right text-sm">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
value: cart.total,
|
value: cart.total,
|
||||||
currencyCode: cart.currency_code,
|
currencyCode: cart.currency_code,
|
||||||
@@ -129,11 +150,9 @@ export default function Cart({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex sm:flex-row flex-col gap-y-6 py-4 sm:py-8 gap-x-4">
|
<div className="flex flex-col gap-x-4 gap-y-6 py-4 sm:flex-row sm:py-8">
|
||||||
{IS_DISCOUNT_SHOWN && (
|
{IS_DISCOUNT_SHOWN && (
|
||||||
<Card
|
<Card className="flex w-full flex-col justify-between sm:w-1/2">
|
||||||
className="flex flex-col justify-between w-full sm:w-1/2"
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<h5>
|
<h5>
|
||||||
<Trans i18nKey="cart:discountCode.title" />
|
<Trans i18nKey="cart:discountCode.title" />
|
||||||
@@ -146,24 +165,31 @@ export default function Cart({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLocationsShown && (
|
{isLocationsShown && (
|
||||||
<Card
|
<Card className="flex w-full flex-col justify-between sm:w-1/2">
|
||||||
className="flex flex-col justify-between w-full sm:w-1/2"
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<h5>
|
<h5>
|
||||||
<Trans i18nKey="cart:locations.title" />
|
<Trans i18nKey="cart:locations.title" />
|
||||||
</h5>
|
</h5>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-full">
|
<CardContent className="h-full">
|
||||||
<AnalysisLocation cart={{ ...cart }} synlabAnalyses={synlabAnalyses} />
|
<AnalysisLocation
|
||||||
|
cart={{ ...cart }}
|
||||||
|
synlabAnalyses={synlabAnalyses}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button className="h-10" onClick={initiatePayment} disabled={isInitiatingSession}>
|
<Button
|
||||||
{isInitiatingSession && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
className="h-10"
|
||||||
|
onClick={initiatePayment}
|
||||||
|
disabled={isInitiatingSession}
|
||||||
|
>
|
||||||
|
{isInitiatingSession && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
<Trans i18nKey="cart:checkout.goToCheckout" />
|
<Trans i18nKey="cart:checkout.goToCheckout" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import { StoreCartLineItem } from "@medusajs/types";
|
||||||
|
import { Reservation } from "~/lib/types/reservation";
|
||||||
|
|
||||||
export interface MontonioOrderToken {
|
export interface MontonioOrderToken {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
accessKey: string;
|
accessKey: string;
|
||||||
merchantReference: string;
|
merchantReference: string;
|
||||||
merchantReferenceDisplay: string;
|
merchantReferenceDisplay: string;
|
||||||
paymentStatus:
|
paymentStatus:
|
||||||
| 'PAID'
|
| 'PAID'
|
||||||
| 'FAILED'
|
| 'FAILED'
|
||||||
| 'CANCELLED'
|
| 'CANCELLED'
|
||||||
| 'PENDING'
|
| 'PENDING'
|
||||||
| 'EXPIRED'
|
| 'EXPIRED'
|
||||||
| 'REFUNDED';
|
| 'REFUNDED';
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
grandTotal: number;
|
grandTotal: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -19,4 +22,11 @@ export interface MontonioOrderToken {
|
|||||||
paymentLinkUuid: string;
|
paymentLinkUuid: string;
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CartItemType {
|
||||||
|
analysisOrders = 'analysisOrders',
|
||||||
|
ttoServices = 'ttoServices',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation };
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { updateLineItem } from '@lib/data/cart';
|
||||||
import { StoreProductVariant } from '@medusajs/types';
|
import { StoreProductVariant } from '@medusajs/types';
|
||||||
|
|
||||||
import { updateLineItem } from "@lib/data/cart";
|
|
||||||
import {
|
|
||||||
createInitialReservation
|
|
||||||
} from '~/lib/services/connected-online.service';
|
|
||||||
import { handleAddToCart } from '~/lib/services/medusaCart.service';
|
import { handleAddToCart } from '~/lib/services/medusaCart.service';
|
||||||
|
import { createInitialReservation } from '~/lib/services/reservation.service';
|
||||||
|
|
||||||
export async function createInitialReservationAction(
|
export async function createInitialReservationAction(
|
||||||
selectedVariant: Pick<StoreProductVariant, 'id'>,
|
selectedVariant: Pick<StoreProductVariant, 'id'>,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from '@/lib/types/connected-online';
|
} from '@/lib/types/connected-online';
|
||||||
import { ExternalApi } from '@/lib/types/external';
|
import { ExternalApi } from '@/lib/types/external';
|
||||||
import { Tables } from '@/packages/supabase/src/database.types';
|
import { Tables } from '@/packages/supabase/src/database.types';
|
||||||
import { StoreOrder } from '@medusajs/types';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { uniq, uniqBy } from 'lodash';
|
import { uniq, uniqBy } from 'lodash';
|
||||||
|
|
||||||
@@ -20,14 +19,15 @@ import { getLogger } from '@kit/shared/logger';
|
|||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { TimeSlotResponse } from '~/home/(user)/_components/booking/booking.context';
|
import { TimeSlotResponse } from '~/home/(user)/_components/booking/booking.context';
|
||||||
|
|
||||||
import { sendEmailFromTemplate } from './mailer.service';
|
import { sendEmailFromTemplate } from './mailer.service';
|
||||||
import { handleDeleteCartItem } from './medusaCart.service';
|
|
||||||
|
|
||||||
export async function getAvailableAppointmentsForService(
|
export async function getAvailableAppointmentsForService(
|
||||||
serviceId: number,
|
serviceId: number,
|
||||||
key: string,
|
key: string,
|
||||||
locationId: number | null,
|
locationId: number | null,
|
||||||
startTime?: Date,
|
startTime?: Date,
|
||||||
|
maxDays?: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const start = startTime ? { StartTime: startTime } : {};
|
const start = startTime ? { StartTime: startTime } : {};
|
||||||
@@ -41,7 +41,7 @@ export async function getAvailableAppointmentsForService(
|
|||||||
ServiceID: serviceId,
|
ServiceID: serviceId,
|
||||||
Key: key,
|
Key: key,
|
||||||
Lang: 'et',
|
Lang: 'et',
|
||||||
MaxDays: 120,
|
MaxDays: maxDays ?? 120,
|
||||||
LocationId: locationId ?? -1,
|
LocationId: locationId ?? -1,
|
||||||
...start,
|
...start,
|
||||||
}),
|
}),
|
||||||
@@ -202,8 +202,8 @@ export async function bookAppointment(
|
|||||||
},
|
},
|
||||||
param: JSON.stringify({
|
param: JSON.stringify({
|
||||||
ClinicID: clinic.id,
|
ClinicID: clinic.id,
|
||||||
ServiceID: service.id,
|
ServiceID: service.sync_id,
|
||||||
ClinicServiceID: service.sync_id,
|
ClinicServiceID: service.id,
|
||||||
UserID: appointmentUserId,
|
UserID: appointmentUserId,
|
||||||
SyncUserID: syncUserID,
|
SyncUserID: syncUserID,
|
||||||
StartTime: startTime,
|
StartTime: startTime,
|
||||||
@@ -416,102 +416,3 @@ export async function getAvailableTimeSlotsForDisplay(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInitialReservation(
|
|
||||||
serviceId: number,
|
|
||||||
clinicId: number,
|
|
||||||
appointmentUserId: number,
|
|
||||||
syncUserID: number,
|
|
||||||
startTime: Date,
|
|
||||||
medusaLineItemId: string,
|
|
||||||
locationId?: number | null,
|
|
||||||
comments = '',
|
|
||||||
) {
|
|
||||||
const logger = await getLogger();
|
|
||||||
const supabase = getSupabaseServerClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
const userId = user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new Error('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'Creating reservation' +
|
|
||||||
JSON.stringify({ serviceId, clinicId, startTime, userId }),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: createdReservation } = await supabase
|
|
||||||
.schema('medreport')
|
|
||||||
.from('connected_online_reservation')
|
|
||||||
.insert({
|
|
||||||
clinic_id: clinicId,
|
|
||||||
comments,
|
|
||||||
lang: 'et',
|
|
||||||
service_id: serviceId,
|
|
||||||
service_user_id: appointmentUserId,
|
|
||||||
start_time: startTime.toString(),
|
|
||||||
sync_user_id: syncUserID,
|
|
||||||
user_id: userId,
|
|
||||||
status: 'PENDING',
|
|
||||||
medusa_cart_line_item_id: medusaLineItemId,
|
|
||||||
location_sync_id: locationId,
|
|
||||||
})
|
|
||||||
.select('id')
|
|
||||||
.single()
|
|
||||||
.throwOnError();
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Created reservation ${JSON.stringify({ createdReservation, userId })}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return createdReservation;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to create initial reservation ${JSON.stringify({ serviceId, clinicId, startTime })} ${e}`,
|
|
||||||
);
|
|
||||||
await handleDeleteCartItem({ lineId: medusaLineItemId });
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelReservation(medusaLineItemId: string) {
|
|
||||||
const supabase = getSupabaseServerClient();
|
|
||||||
|
|
||||||
return supabase
|
|
||||||
.schema('medreport')
|
|
||||||
.from('connected_online_reservation')
|
|
||||||
.update({
|
|
||||||
status: 'CANCELLED',
|
|
||||||
})
|
|
||||||
.eq('medusa_cart_line_item_id', medusaLineItemId)
|
|
||||||
.throwOnError();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getOrderedTtoServices({
|
|
||||||
medusaOrder,
|
|
||||||
}: {
|
|
||||||
medusaOrder: StoreOrder;
|
|
||||||
}) {
|
|
||||||
const supabase = getSupabaseServerClient();
|
|
||||||
|
|
||||||
const ttoReservationIds: number[] =
|
|
||||||
medusaOrder.items
|
|
||||||
?.filter(({ metadata }) => !!metadata?.connectedOnlineReservationId)
|
|
||||||
.map(({ metadata }) => Number(metadata!.connectedOnlineReservationId)) ??
|
|
||||||
[];
|
|
||||||
|
|
||||||
const { data: orderedTtoServices } = await supabase
|
|
||||||
.schema('medreport')
|
|
||||||
.from('connected_online_reservation')
|
|
||||||
.select('*')
|
|
||||||
.in('id', ttoReservationIds)
|
|
||||||
.throwOnError();
|
|
||||||
|
|
||||||
return orderedTtoServices;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,8 +6,23 @@ import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
|
|||||||
import { getCartId } from '@lib/data/cookies';
|
import { getCartId } from '@lib/data/cookies';
|
||||||
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
|
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { cancelReservation } from './connected-online.service';
|
|
||||||
|
|
||||||
|
|
||||||
|
import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { isSameMinute } from 'date-fns';
|
||||||
|
import { getAvailableAppointmentsForService } from './connected-online.service';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const env = () =>
|
const env = () =>
|
||||||
z
|
z
|
||||||
@@ -102,6 +117,30 @@ export async function handleNavigateToPayment({
|
|||||||
if (!cart) {
|
if (!cart) {
|
||||||
throw new Error('No cart found');
|
throw new Error('No cart found');
|
||||||
}
|
}
|
||||||
|
const orderedTtoServices = await getOrderedTtoServices({ cart });
|
||||||
|
|
||||||
|
if (orderedTtoServices?.length) {
|
||||||
|
const unavailableLineItemIds: string[] = []
|
||||||
|
for (const ttoService of orderedTtoServices) {
|
||||||
|
const availabilities = await getAvailableAppointmentsForService(
|
||||||
|
ttoService.service_id,
|
||||||
|
ttoService.provider.key,
|
||||||
|
ttoService.location_sync_id,
|
||||||
|
new Date(ttoService.start_time),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const isAvailable = availabilities?.T_Booking?.length ? availabilities.T_Booking.find((timeSlot) => isSameMinute(ttoService.start_time, timeSlot.StartTime)) : false
|
||||||
|
|
||||||
|
if (!isAvailable) {
|
||||||
|
unavailableLineItemIds.push(ttoService.medusa_cart_line_item_id!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unavailableLineItemIds.length) {
|
||||||
|
return { unavailableLineItemIds }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const paymentLink =
|
const paymentLink =
|
||||||
await new MontonioOrderHandlerService().getMontonioPaymentLink({
|
await new MontonioOrderHandlerService().getMontonioPaymentLink({
|
||||||
@@ -120,11 +159,12 @@ export async function handleNavigateToPayment({
|
|||||||
cart_id: cart.id,
|
cart_id: cart.id,
|
||||||
changed_by: user.id,
|
changed_by: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error('Error logging cart entry: ' + error.message);
|
throw new Error('Error logging cart entry: ' + error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return paymentLink;
|
return { url: paymentLink };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleLineItemTimeout({
|
export async function handleLineItemTimeout({
|
||||||
@@ -149,4 +189,4 @@ export async function handleLineItemTimeout({
|
|||||||
if (error) {
|
if (error) {
|
||||||
throw new Error('Error logging cart entry: ' + error.message);
|
throw new Error('Error logging cart entry: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,8 +154,10 @@ export async function getAnalysisOrdersAdmin({
|
|||||||
|
|
||||||
export async function getTtoOrders({
|
export async function getTtoOrders({
|
||||||
orderStatus,
|
orderStatus,
|
||||||
|
lineItemIds
|
||||||
}: {
|
}: {
|
||||||
orderStatus?: Tables<{ schema: 'medreport' }, 'connected_online_reservation'>['status'];
|
orderStatus?: Tables<{ schema: 'medreport' }, 'connected_online_reservation'>['status'];
|
||||||
|
lineItemIds?: string[]
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
@@ -171,9 +173,15 @@ export async function getTtoOrders({
|
|||||||
.from('connected_online_reservation')
|
.from('connected_online_reservation')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq("user_id", user.id)
|
.eq("user_id", user.id)
|
||||||
|
|
||||||
if (orderStatus) {
|
if (orderStatus) {
|
||||||
query.eq('status', orderStatus);
|
query.eq('status', orderStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lineItemIds?.length) {
|
||||||
|
query.in('medusa_cart_line_item_id', lineItemIds)
|
||||||
|
}
|
||||||
|
|
||||||
const orders = await query.order('created_at', { ascending: false }).throwOnError();
|
const orders = await query.order('created_at', { ascending: false }).throwOnError();
|
||||||
return orders.data;
|
return orders.data;
|
||||||
}
|
}
|
||||||
333
lib/services/reservation.service.ts
Normal file
333
lib/services/reservation.service.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
import { listRegions } from '@lib/data/regions';
|
||||||
|
import { StoreCart, StoreOrder } from '@medusajs/types';
|
||||||
|
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { Tables } from '@kit/supabase/database';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { EnrichedCartItem } from '../../app/home/(user)/_components/cart/types';
|
||||||
|
import { loadCurrentUserAccount } from '../../app/home/(user)/_lib/server/load-user-account';
|
||||||
|
import { handleDeleteCartItem } from './medusaCart.service';
|
||||||
|
|
||||||
|
type Locations = Tables<{ schema: 'medreport' }, 'connected_online_locations'>;
|
||||||
|
type Services = Tables<{ schema: 'medreport' }, 'connected_online_services'>;
|
||||||
|
type ServiceProviders = Tables<
|
||||||
|
{ schema: 'medreport' },
|
||||||
|
'connected_online_service_providers'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export async function getCartReservations(
|
||||||
|
medusaCart: StoreCart,
|
||||||
|
): Promise<EnrichedCartItem[]> {
|
||||||
|
const supabase = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const cartLineItemIds = medusaCart.items?.map(({ id }) => id);
|
||||||
|
|
||||||
|
if (!cartLineItemIds?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: reservations } = await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('connected_online_reservation')
|
||||||
|
.select(
|
||||||
|
'id, startTime:start_time, service:service_id, location:location_sync_id, serviceProvider:service_user_id, medusaCartLineItemId:medusa_cart_line_item_id',
|
||||||
|
)
|
||||||
|
.in('medusa_cart_line_item_id', cartLineItemIds)
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
const locationSyncIds: number[] =
|
||||||
|
reservations
|
||||||
|
?.filter((reservation) => !!reservation.location)
|
||||||
|
.map((reservation) => reservation.location!) ?? [];
|
||||||
|
const serviceIds =
|
||||||
|
reservations?.map((reservation) => reservation.service) ?? [];
|
||||||
|
const serviceProviderIds =
|
||||||
|
reservations.map((reservation) => reservation.serviceProvider) ?? [];
|
||||||
|
|
||||||
|
let locations:
|
||||||
|
| {
|
||||||
|
syncId: Locations['sync_id'];
|
||||||
|
name: Locations['name'];
|
||||||
|
address: Locations['address'];
|
||||||
|
}[]
|
||||||
|
| null = null;
|
||||||
|
if (locationSyncIds.length) {
|
||||||
|
({ data: locations } = await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('connected_online_locations')
|
||||||
|
.select('syncId:sync_id, name, address')
|
||||||
|
.in('sync_id', locationSyncIds)
|
||||||
|
.throwOnError());
|
||||||
|
}
|
||||||
|
|
||||||
|
let services:
|
||||||
|
| {
|
||||||
|
id: Services['id'];
|
||||||
|
name: Services['name'];
|
||||||
|
}[]
|
||||||
|
| null = null;
|
||||||
|
if (serviceIds.length) {
|
||||||
|
({ data: services } = await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('connected_online_services')
|
||||||
|
.select('name, id')
|
||||||
|
.in('id', serviceIds)
|
||||||
|
.throwOnError());
|
||||||
|
}
|
||||||
|
|
||||||
|
let serviceProviders:
|
||||||
|
| {
|
||||||
|
id: ServiceProviders['id'];
|
||||||
|
name: ServiceProviders['name'];
|
||||||
|
jobTitleEt: ServiceProviders['job_title_et'];
|
||||||
|
jobTitleEn: ServiceProviders['job_title_en'];
|
||||||
|
jobTitleRu: ServiceProviders['job_title_ru'];
|
||||||
|
spokenLanguages: ServiceProviders['spoken_languages'];
|
||||||
|
}[]
|
||||||
|
| null = null;
|
||||||
|
if (serviceProviderIds.length) {
|
||||||
|
({ data: serviceProviders } = await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('connected_online_service_providers')
|
||||||
|
.select(
|
||||||
|
'id, name, jobTitleEt:job_title_et, jobTitleEn:job_title_en, jobTitleRu:job_title_ru, spokenLanguages:spoken_languages',
|
||||||
|
)
|
||||||
|
.in('id', serviceProviderIds)
|
||||||
|
.throwOnError());
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const reservation of reservations) {
|
||||||
|
if (reservation.medusaCartLineItemId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartLineItem = medusaCart.items?.find(
|
||||||
|
(item) => item.id === reservation.medusaCartLineItemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cartLineItem) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = locations?.find(
|
||||||
|
(location) => location.syncId === reservation.location,
|
||||||
|
);
|
||||||
|
const service = services?.find(
|
||||||
|
(service) => service.id === reservation.service,
|
||||||
|
);
|
||||||
|
const serviceProvider = serviceProviders?.find(
|
||||||
|
(serviceProvider) => serviceProvider.id === reservation.serviceProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
const countryCodes = await listRegions().then((regions) =>
|
||||||
|
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const enrichedReservation = {
|
||||||
|
...reservation,
|
||||||
|
location,
|
||||||
|
service,
|
||||||
|
serviceProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
...cartLineItem,
|
||||||
|
reservation: {
|
||||||
|
...enrichedReservation,
|
||||||
|
medusaCartLineItemId: reservation.medusaCartLineItemId!,
|
||||||
|
countryCode: countryCodes[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInitialReservation(
|
||||||
|
serviceId: number,
|
||||||
|
clinicId: number,
|
||||||
|
appointmentUserId: number,
|
||||||
|
syncUserID: number,
|
||||||
|
startTime: Date,
|
||||||
|
medusaLineItemId: string,
|
||||||
|
locationId?: number | null,
|
||||||
|
comments = '',
|
||||||
|
) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
const supabase = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const userId = user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('User not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Creating reservation' +
|
||||||
|
JSON.stringify({ serviceId, clinicId, startTime, userId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: createdReservation } = await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('connected_online_reservation')
|
||||||
|
.insert({
|
||||||
|
clinic_id: clinicId,
|
||||||
|
comments,
|
||||||
|
lang: 'et',
|
||||||
|
service_id: serviceId,
|
||||||
|
service_user_id: appointmentUserId,
|
||||||
|
start_time: startTime.toString(),
|
||||||
|
sync_user_id: syncUserID,
|
||||||
|
user_id: userId,
|
||||||
|
status: 'PENDING',
|
||||||
|
medusa_cart_line_item_id: medusaLineItemId,
|
||||||
|
location_sync_id: locationId,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single()
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Created reservation ${JSON.stringify({ createdReservation, userId })}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return createdReservation;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to create initial reservation ${JSON.stringify({ serviceId, clinicId, startTime })} ${e}`,
|
||||||
|
);
|
||||||
|
await handleDeleteCartItem({ lineId: medusaLineItemId });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelReservation(medusaLineItemId: string) {
|
||||||
|
const supabase = getSupabaseServerClient();
|
||||||
|
|
||||||
|
return supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('connected_online_reservation')
|
||||||
|
.update({
|
||||||
|
status: 'CANCELLED',
|
||||||
|
})
|
||||||
|
.eq('medusa_cart_line_item_id', medusaLineItemId)
|
||||||
|
.throwOnError();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrderedTtoServices({
|
||||||
|
cart,
|
||||||
|
medusaOrder,
|
||||||
|
}: {
|
||||||
|
cart?: StoreCart;
|
||||||
|
medusaOrder?: StoreOrder;
|
||||||
|
}) {
|
||||||
|
const supabase = getSupabaseServerClient();
|
||||||
|
|
||||||
|
if (!medusaOrder && !cart) {
|
||||||
|
throw new Error('No cart or medusa order provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttoReservationIds: number[] =
|
||||||
|
(medusaOrder?.items ?? cart?.items)
|
||||||
|
?.filter(({ metadata }) => !!metadata?.connectedOnlineReservationId)
|
||||||
|
.map(({ metadata }) => Number(metadata!.connectedOnlineReservationId)) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
const { data: orderedTtoServices } = await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('connected_online_reservation')
|
||||||
|
.select('*, provider:connected_online_providers(key)')
|
||||||
|
.in('id', ttoReservationIds)
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
return orderedTtoServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateReservationTime(
|
||||||
|
reservationId: number,
|
||||||
|
newStartTime: Date,
|
||||||
|
newServiceId: number,
|
||||||
|
newAppointmentUserId: number,
|
||||||
|
newSyncUserId: number,
|
||||||
|
newLocationId: number | null, // TODO stop allowing null when Connected starts returning the correct ids instead of -1
|
||||||
|
cartId: string,
|
||||||
|
) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
const supabase = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const userId = user?.id;
|
||||||
|
const { account } = await loadCurrentUserAccount();
|
||||||
|
|
||||||
|
if (!userId || !account) {
|
||||||
|
throw new Error('User not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservationData = JSON.stringify({
|
||||||
|
reservationId,
|
||||||
|
newStartTime,
|
||||||
|
newServiceId,
|
||||||
|
newAppointmentUserId,
|
||||||
|
newSyncUserId,
|
||||||
|
newLocationId,
|
||||||
|
userId,
|
||||||
|
cartId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Updating reservation' + reservationData);
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('connected_online_reservation')
|
||||||
|
.update({
|
||||||
|
service_id: newServiceId,
|
||||||
|
service_user_id: newAppointmentUserId,
|
||||||
|
sync_user_id: newSyncUserId,
|
||||||
|
start_time: newStartTime.toString(),
|
||||||
|
location_sync_id: newLocationId,
|
||||||
|
})
|
||||||
|
.eq('id', reservationId)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
logger.info(`Successfully updated reservation ${reservationData}`);
|
||||||
|
await supabase
|
||||||
|
.schema('audit')
|
||||||
|
.from('cart_entries')
|
||||||
|
.insert({
|
||||||
|
operation: 'CHANGE_RESERVATION',
|
||||||
|
account_id: account.id,
|
||||||
|
cart_id: cartId,
|
||||||
|
changed_by: user.id,
|
||||||
|
comment: `${reservationData}`,
|
||||||
|
});
|
||||||
|
revalidatePath('/home/cart', 'layout');
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to update reservation ${reservationData}`);
|
||||||
|
await supabase
|
||||||
|
.schema('audit')
|
||||||
|
.from('cart_entries')
|
||||||
|
.insert({
|
||||||
|
operation: 'CHANGE_RESERVATION',
|
||||||
|
account_id: account.id,
|
||||||
|
cart_id: cartId,
|
||||||
|
changed_by: user.id,
|
||||||
|
comment: `${e}`,
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
lib/types/reservation.ts
Normal file
35
lib/types/reservation.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
export const LocationSchema = z.object({
|
||||||
|
syncId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
address: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type Location = z.infer<typeof LocationSchema>;
|
||||||
|
|
||||||
|
export const ServiceSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
id: z.number(),
|
||||||
|
});
|
||||||
|
export type Service = z.infer<typeof ServiceSchema>;
|
||||||
|
|
||||||
|
export const ServiceProviderSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
jobTitleEt: z.string().nullable(),
|
||||||
|
jobTitleEn: z.string().nullable(),
|
||||||
|
jobTitleRu: z.string().nullable(),
|
||||||
|
spokenLanguages: z.array(z.string()).nullable(),
|
||||||
|
});
|
||||||
|
export type ServiceProvider = z.infer<typeof ServiceProviderSchema>;
|
||||||
|
|
||||||
|
export const ReservationSchema = z.object({
|
||||||
|
startTime: z.string(),
|
||||||
|
service: ServiceSchema.optional(),
|
||||||
|
location: LocationSchema.optional(),
|
||||||
|
serviceProvider: ServiceProviderSchema.optional(),
|
||||||
|
medusaCartLineItemId: z.string(),
|
||||||
|
id: z.number(),
|
||||||
|
countryCode: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type Reservation = z.infer<typeof ReservationSchema>;
|
||||||
@@ -1137,7 +1137,22 @@ export type Database = {
|
|||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "fk_reservation_clinic"
|
||||||
|
columns: ["clinic_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "connected_online_providers"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "fk_reservation_service"
|
||||||
|
columns: ["service_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "connected_online_services"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
connected_online_service_providers: {
|
connected_online_service_providers: {
|
||||||
Row: {
|
Row: {
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
"description": "Get to know the personal analysis packages and order"
|
"description": "Get to know the personal analysis packages and order"
|
||||||
},
|
},
|
||||||
"noCategories": "List of services not found, try again later",
|
"noCategories": "List of services not found, try again later",
|
||||||
"noResults": "No availabilities found for selected dates"
|
"noResults": "No availabilities found for selected dates",
|
||||||
|
"serviceNotFound": "Service not found",
|
||||||
|
"bookTimeLoading": "Selecting time...",
|
||||||
|
"noProducts": "No products found",
|
||||||
|
"timeSlotUnavailable": "Service availability has changed, please select a new time"
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"item": "Item",
|
"item": "Item",
|
||||||
"quantity": "Quantity",
|
"quantity": "Quantity",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
"total": "Total"
|
"total": "Total",
|
||||||
|
"time": "Time",
|
||||||
|
"location": "Location"
|
||||||
},
|
},
|
||||||
"checkout": {
|
"checkout": {
|
||||||
"goToCheckout": "Go to checkout",
|
"goToCheckout": "Go to checkout",
|
||||||
@@ -82,5 +84,9 @@
|
|||||||
"title": "Location for analysis",
|
"title": "Location for analysis",
|
||||||
"description": "If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point.",
|
"description": "If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point.",
|
||||||
"locationSelect": "Select location"
|
"locationSelect": "Select location"
|
||||||
|
},
|
||||||
|
"editServiceItem": {
|
||||||
|
"title": "Edit booking",
|
||||||
|
"description": "Edit booking details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,5 +13,8 @@
|
|||||||
"showAllLocations": "Näita kõiki asutusi",
|
"showAllLocations": "Näita kõiki asutusi",
|
||||||
"bookTimeSuccess": "Aeg valitud",
|
"bookTimeSuccess": "Aeg valitud",
|
||||||
"bookTimeError": "Aega ei õnnestunud valida",
|
"bookTimeError": "Aega ei õnnestunud valida",
|
||||||
"bookTimeLoading": "Aega valitakse..."
|
"bookTimeLoading": "Aega valitakse...",
|
||||||
|
"serviceNotFound": "Teenust ei leitud",
|
||||||
|
"noProducts": "Tooteid ei leitud",
|
||||||
|
"timeSlotUnavailable": "Teenuse saadavus muutus, palun vali uus aeg"
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"item": "Toode",
|
"item": "Toode",
|
||||||
"quantity": "Kogus",
|
"quantity": "Kogus",
|
||||||
"price": "Hind",
|
"price": "Hind",
|
||||||
"total": "Summa"
|
"total": "Summa",
|
||||||
|
"time": "Aeg",
|
||||||
|
"location": "Asukoht"
|
||||||
},
|
},
|
||||||
"checkout": {
|
"checkout": {
|
||||||
"goToCheckout": "Vormista ost",
|
"goToCheckout": "Vormista ost",
|
||||||
@@ -84,5 +86,9 @@
|
|||||||
"title": "Asukoht analüüside andmiseks",
|
"title": "Asukoht analüüside andmiseks",
|
||||||
"description": "Kui Teil ei ole võimalik valitud asukohta minna analüüse andma, siis võite minna Teile sobivasse verevõtupunkti.",
|
"description": "Kui Teil ei ole võimalik valitud asukohta minna analüüse andma, siis võite minna Teile sobivasse verevõtupunkti.",
|
||||||
"locationSelect": "Vali asukoht"
|
"locationSelect": "Vali asukoht"
|
||||||
|
},
|
||||||
|
"editServiceItem": {
|
||||||
|
"title": "Muuda broneeringut",
|
||||||
|
"description": "Muuda broneeringu andmeid"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,5 +148,6 @@
|
|||||||
"yes": "Jah",
|
"yes": "Jah",
|
||||||
"no": "Ei",
|
"no": "Ei",
|
||||||
"preferNotToAnswer": "Eelistan mitte vastata",
|
"preferNotToAnswer": "Eelistan mitte vastata",
|
||||||
"book": "Broneeri"
|
"book": "Broneeri",
|
||||||
|
"change": "Muuda"
|
||||||
}
|
}
|
||||||
@@ -6,5 +6,9 @@
|
|||||||
"description": "Ознакомьтесь с персональными пакетами анализов и закажите"
|
"description": "Ознакомьтесь с персональными пакетами анализов и закажите"
|
||||||
},
|
},
|
||||||
"noCategories": "Список услуг не найден, попробуйте позже",
|
"noCategories": "Список услуг не найден, попробуйте позже",
|
||||||
"noResults": "Для выбранных дат доступных вариантов не найдено"
|
"noResults": "Для выбранных дат доступных вариантов не найдено",
|
||||||
|
"bookTimeLoading": "Выбор времени...",
|
||||||
|
"serviceNotFound": "Услуга не найдена",
|
||||||
|
"noProducts": "Товары не найдены",
|
||||||
|
"timeSlotUnavailable": "Доступность услуги изменилась, пожалуйста, выберите другое время"
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"item": "Товар",
|
"item": "Товар",
|
||||||
"quantity": "Количество",
|
"quantity": "Количество",
|
||||||
"price": "Цена",
|
"price": "Цена",
|
||||||
"total": "Сумма"
|
"total": "Сумма",
|
||||||
|
"time": "Время",
|
||||||
|
"location": "Местоположение"
|
||||||
},
|
},
|
||||||
"checkout": {
|
"checkout": {
|
||||||
"goToCheckout": "Оформить заказ",
|
"goToCheckout": "Оформить заказ",
|
||||||
@@ -82,5 +84,9 @@
|
|||||||
"title": "Местоположение для сдачи анализов",
|
"title": "Местоположение для сдачи анализов",
|
||||||
"description": "Если у вас нет возможности прийти в выбранное место для сдачи анализов, вы можете посетить любой удобный для вас пункт забора крови.",
|
"description": "Если у вас нет возможности прийти в выбранное место для сдачи анализов, вы можете посетить любой удобный для вас пункт забора крови.",
|
||||||
"locationSelect": "Выберите местоположение"
|
"locationSelect": "Выберите местоположение"
|
||||||
|
},
|
||||||
|
"editServiceItem": {
|
||||||
|
"title": "Изменить бронирование",
|
||||||
|
"description": "Изменить данные бронирования"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE medreport.connected_online_reservation
|
||||||
|
ADD CONSTRAINT fk_reservation_clinic
|
||||||
|
FOREIGN KEY (clinic_id)
|
||||||
|
REFERENCES medreport.connected_online_providers(id);
|
||||||
|
|
||||||
|
ALTER TABLE medreport.connected_online_services
|
||||||
|
ADD CONSTRAINT constraint_name UNIQUE (sync_id);
|
||||||
|
|
||||||
|
ALTER TABLE medreport.connected_online_reservation
|
||||||
|
ADD CONSTRAINT fk_reservation_service
|
||||||
|
FOREIGN KEY (service_id)
|
||||||
|
REFERENCES medreport.connected_online_services(id);
|
||||||
Reference in New Issue
Block a user