add cart functionality for tto services

This commit is contained in:
Helena
2025-09-19 16:23:19 +03:00
parent 3c272505d6
commit b59148630a
26 changed files with 921 additions and 221 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,5 +6,9 @@
"description": "Ознакомьтесь с персональными пакетами анализов и закажите" "description": "Ознакомьтесь с персональными пакетами анализов и закажите"
}, },
"noCategories": "Список услуг не найден, попробуйте позже", "noCategories": "Список услуг не найден, попробуйте позже",
"noResults": "Для выбранных дат доступных вариантов не найдено" "noResults": "Для выбранных дат доступных вариантов не найдено",
"bookTimeLoading": "Выбор времени...",
"serviceNotFound": "Услуга не найдена",
"noProducts": "Товары не найдены",
"timeSlotUnavailable": "Доступность услуги изменилась, пожалуйста, выберите другое время"
} }

View File

@@ -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": "Изменить данные бронирования"
} }
} }

View File

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