diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts
index 2cc0447..07b7435 100644
--- a/app/api/job/handler/sync-connected-online.ts
+++ b/app/api/job/handler/sync-connected-online.ts
@@ -1,6 +1,5 @@
import axios from 'axios';
-import { Tables } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { logSyncResult } from '~/lib/services/audit.service';
@@ -131,7 +130,7 @@ export default async function syncConnectedOnline() {
return {
id: service.ID,
clinic_id: service.ClinicID,
- sync_id: service.SyncID,
+ sync_id: Number(service.SyncID),
name: service.Name,
description: service.Description || null,
price: service.Price,
diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts
index ff97af4..806588e 100644
--- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts
+++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts
@@ -12,13 +12,13 @@ import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import {
bookAppointment,
- getOrderedTtoServices,
} from '~/lib/services/connected-online.service';
import { FailureReason } from '~/lib/types/connected-online';
import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service';
import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service';
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
import { AccountWithParams } from '@kit/accounts/types/accounts';
+import { getOrderedTtoServices } from '~/lib/services/reservation.service';
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
const ANALYSIS_TYPE_HANDLE = 'synlab-analysis';
diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx
index 20efdce..3016fe8 100644
--- a/app/home/(user)/(dashboard)/cart/page.tsx
+++ b/app/home/(user)/(dashboard)/cart/page.tsx
@@ -8,10 +8,12 @@ import { listProductTypes } from '@lib/data/products';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
-
import { findProductTypeIdByHandle } from '~/lib/utils';
+
+import { getCartReservations } from '~/lib/services/reservation.service';
import Cart from '../../_components/cart';
import CartTimer from '../../_components/cart/cart-timer';
+import { EnrichedCartItem } from '../../_components/cart/types';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -37,10 +39,6 @@ async function CartPage() {
productTypes,
'analysis-packages',
);
- const ttoServiceTypeId = findProductTypeIdByHandle(
- productTypes,
- 'tto-service',
- );
const synlabAnalyses =
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) =>
(a.updated_at ?? '') > (b.updated_at ?? '') ? -1 : 1,
);
diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx
index 77caa97..9d3b6e8 100644
--- a/app/home/(user)/_components/booking/booking-container.tsx
+++ b/app/home/(user)/_components/booking/booking-container.tsx
@@ -1,22 +1,46 @@
'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 { BookingProvider } from './booking.provider';
import LocationSelector from './location-selector';
import ServiceSelector from './service-selector';
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) {
+
+
+
;
+ }
+
return (
-
+
);
diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx
index 3f3cb18..2c23a9f 100644
--- a/app/home/(user)/_components/booking/booking.provider.tsx
+++ b/app/home/(user)/_components/booking/booking.provider.tsx
@@ -4,7 +4,6 @@ import { StoreProduct } from '@medusajs/types';
import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service';
-import { ServiceCategory } from '../service-categories';
import { BookingContext, Location, TimeSlot } from './booking.context';
export function useBooking() {
@@ -19,10 +18,11 @@ export function useBooking() {
export const BookingProvider: React.FC<{
children: React.ReactElement;
- category: ServiceCategory;
-}> = ({ children, category }) => {
+ category: { products: StoreProduct[] };
+ service?: StoreProduct;
+}> = ({ children, category, service }) => {
const [selectedService, setSelectedService] = useState(
- category.products[0] || null,
+ (service ?? category?.products?.[0]) || null,
);
const [selectedLocationId, setSelectedLocationId] = useState(
null,
@@ -32,6 +32,7 @@ export const BookingProvider: React.FC<{
const [locations, setLocations] = useState(null);
const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false);
+
useEffect(() => {
let metadataServiceIds = [];
try {
diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx
index 42540d1..8dd0907 100644
--- a/app/home/(user)/_components/booking/service-selector.tsx
+++ b/app/home/(user)/_components/booking/service-selector.tsx
@@ -18,7 +18,7 @@ import { useBooking } from './booking.provider';
const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
const { selectedService, setSelectedService } = useBooking();
const [collapsed, setCollapsed] = useState(false);
- const [firstFourProducts] = useState(products.slice(0, 4));
+ const [firstFourProducts] = useState(products?.slice(0, 4));
const onServiceSelect = async (productId: StoreProduct['id']) => {
const product = products.find((p) => p.id === productId);
@@ -38,7 +38,7 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
className="mb-2 flex flex-col"
onValueChange={onServiceSelect}
>
- {firstFourProducts.map((product) => (
+ {firstFourProducts?.map((product) => (
{
))}
-
- setCollapsed((_) => !_)}
- className="flex cursor-pointer items-center justify-between border-t py-1"
- >
-
-
-
-
-
-
+ {products.length > 4 && (
+
+ setCollapsed((_) => !_)}
+ className="flex cursor-pointer items-center justify-between border-t py-1"
+ >
+
+
+
+
+
+
+ )}
- {products.map((product) => (
+ {products?.map((product) => (
{
+const TimeSlots = ({
+ countryCode,
+ cartItem,
+ onComplete,
+}: {
+ countryCode: string;
+ cartItem?: EnrichedCartItem;
+ onComplete?: () => void;
+}) => {
const [currentPage, setCurrentPage] = useState(1);
const {
@@ -133,6 +144,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
booking.selectedLocationId ? booking.selectedLocationId : null,
comments,
).then(() => {
+ if (onComplete) {
+ onComplete();
+ }
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: ,
+ error: ,
+ loading: ,
+ });
+
+ 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 (
@@ -154,9 +211,12 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
);
const price =
booking.selectedService?.variants?.[0]?.calculated_price
- ?.calculated_amount;
+ ?.calculated_amount ?? cartItem?.unit_price;
return (
-
+
{formatDateAndTime(timeSlot.StartTime.toString())}
@@ -177,11 +237,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
)}
{isEHIF && {t('booking:ehifBooking')}}
-
- {timeSlot.location?.address}
-
+
{timeSlot.location?.address}
-
+
{formatCurrency({
currencyCode: 'EUR',
@@ -189,7 +247,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
value: price ?? '',
})}
-
diff --git a/app/home/(user)/_components/cart/cart-service-item.tsx b/app/home/(user)/_components/cart/cart-service-item.tsx
new file mode 100644
index 0000000..a8d2bd7
--- /dev/null
+++ b/app/home/(user)/_components/cart/cart-service-item.tsx
@@ -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 (
+
+ );
+};
+
+export default function CartServiceItem({
+ item,
+ currencyCode,
+ isUnavailable,
+}: {
+ item: EnrichedCartItem;
+ currencyCode: string;
+ isUnavailable?: boolean;
+}) {
+ const [editingItem, setEditingItem] = useState
(null);
+ const {
+ i18n: { language },
+ } = useTranslation();
+
+ return (
+ <>
+
+
+
+ {item.product_title}
+
+
+
+
+ {formatDateAndTime(item.reservation.startTime.toString())}
+
+
+
+ {item.reservation.location?.address ?? '-'}
+
+
+ {item.quantity}
+
+
+ {formatCurrency({
+ value: item.unit_price,
+ currencyCode,
+ locale: language,
+ })}
+
+
+
+ {formatCurrency({
+ value: item.total,
+ currencyCode,
+ locale: language,
+ })}
+
+
+
+
+ setEditingItem(item)}>
+
+
+
+
+
+
+
+
+
+
+
+ {isUnavailable &&
+
+
+
+ }
+ setEditingItem(null)}
+ />
+ >
+ );
+}
diff --git a/app/home/(user)/_components/cart/cart-service-items.tsx b/app/home/(user)/_components/cart/cart-service-items.tsx
new file mode 100644
index 0000000..af39aa3
--- /dev/null
+++ b/app/home/(user)/_components/cart/cart-service-items.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {items
+ .sort((a, b) =>
+ (a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
+ )
+ .map((item) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx
index 09064bd..5775906 100644
--- a/app/home/(user)/_components/cart/index.tsx
+++ b/app/home/(user)/_components/cart/index.tsx
@@ -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 {
- Card,
- CardContent,
- CardHeader,
-} from '@kit/ui/card';
-import DiscountCode from "./discount-code";
-import { initiatePaymentSession } from "@lib/data/cart";
-import { formatCurrency } from "@/packages/shared/src/utils";
-import { useTranslation } from "react-i18next";
-import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
-import AnalysisLocation from "./analysis-location";
+import { Card, CardContent, CardHeader } from '@kit/ui/card';
+import { Trans } from '@kit/ui/trans';
+
+import AnalysisLocation from './analysis-location';
+import CartItems from './cart-items';
+import CartServiceItems from './cart-service-items';
+import DiscountCode from './discount-code';
+import { EnrichedCartItem } from './types';
const IS_DISCOUNT_SHOWN = true as boolean;
@@ -25,13 +26,16 @@ export default function Cart({
synlabAnalyses,
ttoServiceItems,
}: {
- cart: StoreCart | null
+ cart: StoreCart | null;
synlabAnalyses: StoreCartLineItem[];
- ttoServiceItems: StoreCartLineItem[];
+ ttoServiceItems: EnrichedCartItem[];
}) {
- const { i18n: { language } } = useTranslation();
+ const {
+ i18n: { language },
+ } = useTranslation();
const [isInitiatingSession, setIsInitiatingSession] = useState(false);
+ const [unavailableLineItemIds, setUnavailableLineItemIds] = useState()
const items = cart?.items ?? [];
@@ -39,7 +43,10 @@ export default function Cart({
return (
-
+
@@ -60,8 +67,13 @@ export default function Cart({
if (response.payment_collection) {
const { payment_sessions } = response.payment_collection;
const paymentSessionId = payment_sessions![0]!.id;
- const url = await handleNavigateToPayment({ language, paymentSessionId });
- window.location.href = url;
+ const result = await handleNavigateToPayment({ language, paymentSessionId });
+ if (result.url) {
+ window.location.href = result.url;
+ }
+ if (result.unavailableLineItemIds) {
+ setUnavailableLineItemIds(result.unavailableLineItemIds)
+ }
} else {
setIsInitiatingSession(false);
}
@@ -71,21 +83,30 @@ export default function Cart({
const isLocationsShown = synlabAnalyses.length > 0;
return (
-
-
-
-
+
+
+
+
{hasCartItems && (
<>
-
-
-
+
+
-
+
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
@@ -94,14 +115,14 @@ export default function Cart({
-
-
-
+
+
-
+
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
@@ -110,14 +131,14 @@ export default function Cart({
-
-
-
+
+
-
+
{formatCurrency({
value: cart.total,
currencyCode: cart.currency_code,
@@ -129,11 +150,9 @@ export default function Cart({
>
)}
-
+
{IS_DISCOUNT_SHOWN && (
-
+
@@ -146,24 +165,31 @@ export default function Cart({
)}
{isLocationsShown && (
-
+
-
+
)}
-
- {isInitiatingSession && }
+
+ {isInitiatingSession && (
+
+ )}
diff --git a/app/home/(user)/_components/cart/types.ts b/app/home/(user)/_components/cart/types.ts
index 22385ce..c0154c9 100644
--- a/app/home/(user)/_components/cart/types.ts
+++ b/app/home/(user)/_components/cart/types.ts
@@ -1,15 +1,18 @@
+import { StoreCartLineItem } from "@medusajs/types";
+import { Reservation } from "~/lib/types/reservation";
+
export interface MontonioOrderToken {
uuid: string;
accessKey: string;
merchantReference: string;
merchantReferenceDisplay: string;
paymentStatus:
- | 'PAID'
- | 'FAILED'
- | 'CANCELLED'
- | 'PENDING'
- | 'EXPIRED'
- | 'REFUNDED';
+ | 'PAID'
+ | 'FAILED'
+ | 'CANCELLED'
+ | 'PENDING'
+ | 'EXPIRED'
+ | 'REFUNDED';
paymentMethod: string;
grandTotal: number;
currency: string;
@@ -19,4 +22,11 @@ export interface MontonioOrderToken {
paymentLinkUuid: string;
iat: number;
exp: number;
-}
\ No newline at end of file
+}
+
+export enum CartItemType {
+ analysisOrders = 'analysisOrders',
+ ttoServices = 'ttoServices',
+}
+
+export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation };
diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts
index dfb6f88..43d90b8 100644
--- a/app/home/(user)/_lib/server/actions.ts
+++ b/app/home/(user)/_lib/server/actions.ts
@@ -1,12 +1,10 @@
'use server';
+import { updateLineItem } from '@lib/data/cart';
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 { createInitialReservation } from '~/lib/services/reservation.service';
export async function createInitialReservationAction(
selectedVariant: Pick
,
diff --git a/lib/services/connected-online.service.ts b/lib/services/connected-online.service.ts
index 34abc24..32da35f 100644
--- a/lib/services/connected-online.service.ts
+++ b/lib/services/connected-online.service.ts
@@ -11,7 +11,6 @@ import {
} from '@/lib/types/connected-online';
import { ExternalApi } from '@/lib/types/external';
import { Tables } from '@/packages/supabase/src/database.types';
-import { StoreOrder } from '@medusajs/types';
import axios from 'axios';
import { uniq, uniqBy } from 'lodash';
@@ -20,14 +19,15 @@ import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { TimeSlotResponse } from '~/home/(user)/_components/booking/booking.context';
+
import { sendEmailFromTemplate } from './mailer.service';
-import { handleDeleteCartItem } from './medusaCart.service';
export async function getAvailableAppointmentsForService(
serviceId: number,
key: string,
locationId: number | null,
startTime?: Date,
+ maxDays?: number
) {
try {
const start = startTime ? { StartTime: startTime } : {};
@@ -41,7 +41,7 @@ export async function getAvailableAppointmentsForService(
ServiceID: serviceId,
Key: key,
Lang: 'et',
- MaxDays: 120,
+ MaxDays: maxDays ?? 120,
LocationId: locationId ?? -1,
...start,
}),
@@ -202,8 +202,8 @@ export async function bookAppointment(
},
param: JSON.stringify({
ClinicID: clinic.id,
- ServiceID: service.id,
- ClinicServiceID: service.sync_id,
+ ServiceID: service.sync_id,
+ ClinicServiceID: service.id,
UserID: appointmentUserId,
SyncUserID: syncUserID,
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;
-}
diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts
index cb63b2a..1da98b3 100644
--- a/lib/services/medusaCart.service.ts
+++ b/lib/services/medusaCart.service.ts
@@ -6,8 +6,23 @@ import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
import { getCartId } from '@lib/data/cookies';
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
import { z } from 'zod';
+
+
+
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 = () =>
z
@@ -102,6 +117,30 @@ export async function handleNavigateToPayment({
if (!cart) {
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 =
await new MontonioOrderHandlerService().getMontonioPaymentLink({
@@ -120,11 +159,12 @@ export async function handleNavigateToPayment({
cart_id: cart.id,
changed_by: user.id,
});
+
if (error) {
throw new Error('Error logging cart entry: ' + error.message);
}
- return paymentLink;
+ return { url: paymentLink };
}
export async function handleLineItemTimeout({
@@ -149,4 +189,4 @@ export async function handleLineItemTimeout({
if (error) {
throw new Error('Error logging cart entry: ' + error.message);
}
-}
+}
\ No newline at end of file
diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts
index 8010e5e..422dcac 100644
--- a/lib/services/order.service.ts
+++ b/lib/services/order.service.ts
@@ -154,8 +154,10 @@ export async function getAnalysisOrdersAdmin({
export async function getTtoOrders({
orderStatus,
+ lineItemIds
}: {
orderStatus?: Tables<{ schema: 'medreport' }, 'connected_online_reservation'>['status'];
+ lineItemIds?: string[]
} = {}) {
const client = getSupabaseServerClient();
@@ -171,9 +173,15 @@ export async function getTtoOrders({
.from('connected_online_reservation')
.select('*')
.eq("user_id", user.id)
+
if (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();
return orders.data;
}
\ No newline at end of file
diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts
new file mode 100644
index 0000000..b3a95ac
--- /dev/null
+++ b/lib/services/reservation.service.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/lib/types/reservation.ts b/lib/types/reservation.ts
new file mode 100644
index 0000000..447e745
--- /dev/null
+++ b/lib/types/reservation.ts
@@ -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;
+
+export const ServiceSchema = z.object({
+ name: z.string(),
+ id: z.number(),
+});
+export type Service = z.infer;
+
+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;
+
+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;
diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts
index e00a03d..4676e8d 100644
--- a/packages/supabase/src/database.types.ts
+++ b/packages/supabase/src/database.types.ts
@@ -1137,7 +1137,22 @@ export type Database = {
updated_at?: string | null
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: {
Row: {
diff --git a/public/locales/en/booking.json b/public/locales/en/booking.json
index 8a38d9f..5f30e95 100644
--- a/public/locales/en/booking.json
+++ b/public/locales/en/booking.json
@@ -6,5 +6,9 @@
"description": "Get to know the personal analysis packages and order"
},
"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"
}
\ No newline at end of file
diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json
index 223d3f1..63dbf49 100644
--- a/public/locales/en/cart.json
+++ b/public/locales/en/cart.json
@@ -7,7 +7,9 @@
"item": "Item",
"quantity": "Quantity",
"price": "Price",
- "total": "Total"
+ "total": "Total",
+ "time": "Time",
+ "location": "Location"
},
"checkout": {
"goToCheckout": "Go to checkout",
@@ -82,5 +84,9 @@
"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.",
"locationSelect": "Select location"
+ },
+ "editServiceItem": {
+ "title": "Edit booking",
+ "description": "Edit booking details"
}
}
\ No newline at end of file
diff --git a/public/locales/et/booking.json b/public/locales/et/booking.json
index bc2d2cd..a60b8ec 100644
--- a/public/locales/et/booking.json
+++ b/public/locales/et/booking.json
@@ -13,5 +13,8 @@
"showAllLocations": "Näita kõiki asutusi",
"bookTimeSuccess": "Aeg valitud",
"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"
}
\ No newline at end of file
diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json
index 2b2bfa5..41a77ae 100644
--- a/public/locales/et/cart.json
+++ b/public/locales/et/cart.json
@@ -7,7 +7,9 @@
"item": "Toode",
"quantity": "Kogus",
"price": "Hind",
- "total": "Summa"
+ "total": "Summa",
+ "time": "Aeg",
+ "location": "Asukoht"
},
"checkout": {
"goToCheckout": "Vormista ost",
@@ -84,5 +86,9 @@
"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.",
"locationSelect": "Vali asukoht"
+ },
+ "editServiceItem": {
+ "title": "Muuda broneeringut",
+ "description": "Muuda broneeringu andmeid"
}
}
\ No newline at end of file
diff --git a/public/locales/et/common.json b/public/locales/et/common.json
index 7611586..8405f64 100644
--- a/public/locales/et/common.json
+++ b/public/locales/et/common.json
@@ -148,5 +148,6 @@
"yes": "Jah",
"no": "Ei",
"preferNotToAnswer": "Eelistan mitte vastata",
- "book": "Broneeri"
+ "book": "Broneeri",
+ "change": "Muuda"
}
\ No newline at end of file
diff --git a/public/locales/ru/booking.json b/public/locales/ru/booking.json
index df29269..4987043 100644
--- a/public/locales/ru/booking.json
+++ b/public/locales/ru/booking.json
@@ -6,5 +6,9 @@
"description": "Ознакомьтесь с персональными пакетами анализов и закажите"
},
"noCategories": "Список услуг не найден, попробуйте позже",
- "noResults": "Для выбранных дат доступных вариантов не найдено"
+ "noResults": "Для выбранных дат доступных вариантов не найдено",
+ "bookTimeLoading": "Выбор времени...",
+ "serviceNotFound": "Услуга не найдена",
+ "noProducts": "Товары не найдены",
+ "timeSlotUnavailable": "Доступность услуги изменилась, пожалуйста, выберите другое время"
}
\ No newline at end of file
diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json
index 289ff31..279f3d2 100644
--- a/public/locales/ru/cart.json
+++ b/public/locales/ru/cart.json
@@ -7,7 +7,9 @@
"item": "Товар",
"quantity": "Количество",
"price": "Цена",
- "total": "Сумма"
+ "total": "Сумма",
+ "time": "Время",
+ "location": "Местоположение"
},
"checkout": {
"goToCheckout": "Оформить заказ",
@@ -82,5 +84,9 @@
"title": "Местоположение для сдачи анализов",
"description": "Если у вас нет возможности прийти в выбранное место для сдачи анализов, вы можете посетить любой удобный для вас пункт забора крови.",
"locationSelect": "Выберите местоположение"
+ },
+ "editServiceItem": {
+ "title": "Изменить бронирование",
+ "description": "Изменить данные бронирования"
}
}
\ No newline at end of file
diff --git a/supabase/migrations/20250919121028_add_references_to_reservations.sql b/supabase/migrations/20250919121028_add_references_to_reservations.sql
new file mode 100644
index 0000000..30d9384
--- /dev/null
+++ b/supabase/migrations/20250919121028_add_references_to_reservations.sql
@@ -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);
\ No newline at end of file