} />
@@ -28,9 +39,15 @@ export default async function MontonioCheckoutCallbackErrorPage() {
-
+ {failedBookingData.length ? (
+ failedBookingData.map((failureReason, index) => (
+
+
+
+ ))
+ ) : (
-
+ )}
diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx
index 41dca03..38967f1 100644
--- a/app/home/(user)/(dashboard)/cart/page.tsx
+++ b/app/home/(user)/(dashboard)/cart/page.tsx
@@ -1,5 +1,3 @@
-import { notFound } from 'next/navigation';
-
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { retrieveCart } from '@lib/data/cart';
@@ -8,9 +6,14 @@ import { listProductTypes } from '@lib/data/products';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
+import { getCartReservations } from '~/lib/services/reservation.service';
+import { findProductTypeIdByHandle } from '~/lib/utils';
import Cart from '../../_components/cart';
import CartTimer from '../../_components/cart/cart-timer';
+import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
+import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
+import { EnrichedCartItem } from '../../_components/cart/types';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -21,35 +24,48 @@ export async function generateMetadata() {
}
async function CartPage() {
- const cart = await retrieveCart().catch((error) => {
- console.error('Failed to retrieve cart', error);
- return notFound();
- });
+ const [
+ cart,
+ { productTypes },
+ { account },
+ ] = await Promise.all([
+ retrieveCart(),
+ listProductTypes(),
+ loadCurrentUserAccount(),
+ ]);
- const { productTypes } = await listProductTypes();
- const analysisPackagesType = productTypes.find(
- ({ metadata }) => metadata?.handle === 'analysis-packages',
+ if (!account) {
+ return null;
+ }
+
+ const balanceSummary = await new AccountBalanceService().getBalanceSummary(account.id);
+
+ const synlabAnalysisTypeId = findProductTypeIdByHandle(
+ productTypes,
+ 'synlab-analysis',
);
- const synlabAnalysisType = productTypes.find(
- ({ metadata }) => metadata?.handle === 'synlab-analysis',
+ const analysisPackagesTypeId = findProductTypeIdByHandle(
+ productTypes,
+ 'analysis-packages',
);
+
const synlabAnalyses =
- analysisPackagesType && synlabAnalysisType && cart?.items
+ analysisPackagesTypeId && synlabAnalysisTypeId && cart?.items
? cart.items.filter((item) => {
const productTypeId = item.product?.type_id;
if (!productTypeId) {
return false;
}
- return [analysisPackagesType.id, synlabAnalysisType.id].includes(
+ return [analysisPackagesTypeId, synlabAnalysisTypeId].includes(
productTypeId,
);
})
: [];
- const ttoServiceItems =
- cart?.items?.filter(
- (item) => !synlabAnalyses.some((analysis) => analysis.id === item.id),
- ) ?? [];
+ 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,
);
@@ -63,9 +79,11 @@ async function CartPage() {
{isTimerShown &&
}
);
diff --git a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/order-confirmed-loading-wrapper.tsx b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/order-confirmed-loading-wrapper.tsx
new file mode 100644
index 0000000..939962d
--- /dev/null
+++ b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/order-confirmed-loading-wrapper.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
+import OrderDetails from '@/app/home/(user)/_components/order/order-details';
+import OrderItems from '@/app/home/(user)/_components/order/order-items';
+import Divider from '@modules/common/components/divider';
+
+import { PageBody, PageHeader } from '@kit/ui/page';
+import { Trans } from '@kit/ui/trans';
+
+import { StoreOrder } from '@medusajs/types';
+import { AnalysisOrder } from '~/lib/types/analysis-order';
+import { useEffect, useRef, useState } from 'react';
+import { retrieveOrder } from '@lib/data/orders';
+import { GlobalLoader } from '@kit/ui/makerkit/global-loader';
+
+function OrderConfirmedLoadingWrapper({
+ medusaOrder: initialMedusaOrder,
+ order,
+}: {
+ medusaOrder: StoreOrder;
+ order: AnalysisOrder;
+}) {
+ const [medusaOrder, setMedusaOrder] = useState
(initialMedusaOrder);
+ const fetchingRef = useRef(false);
+
+ const paymentStatus = medusaOrder.payment_status;
+ const medusaOrderId = order.medusa_order_id;
+
+ useEffect(() => {
+ if (paymentStatus === 'captured') {
+ return;
+ }
+
+ const interval = setInterval(async () => {
+ if (fetchingRef.current) {
+ return;
+ }
+
+ fetchingRef.current = true;
+ const medusaOrder = await retrieveOrder(medusaOrderId, false);
+ fetchingRef.current = false;
+
+ setMedusaOrder(medusaOrder);
+ }, 2_000);
+
+ return () => clearInterval(interval);
+ }, [paymentStatus, medusaOrderId]);
+
+ const isPaid = paymentStatus === 'captured';
+
+ if (!isPaid) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ } />
+
+
+
+ );
+}
+
+export default OrderConfirmedLoadingWrapper;
diff --git a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx
index eeebd7b..eaffa1c 100644
--- a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx
+++ b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx
@@ -1,18 +1,13 @@
import { redirect } from 'next/navigation';
-import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
-import OrderDetails from '@/app/home/(user)/_components/order/order-details';
-import OrderItems from '@/app/home/(user)/_components/order/order-items';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { retrieveOrder } from '@lib/data/orders';
-import Divider from '@modules/common/components/divider';
import { pathsConfig } from '@kit/shared/config';
-import { PageBody, PageHeader } from '@kit/ui/page';
-import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getAnalysisOrder } from '~/lib/services/order.service';
+import OrderConfirmedLoadingWrapper from './order-confirmed-loading-wrapper';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -41,18 +36,7 @@ async function OrderConfirmedPage(props: {
redirect(pathsConfig.app.myOrders);
}
- return (
-
- } />
-
-
-
- );
+ return ;
}
export default withI18n(OrderConfirmedPage);
diff --git a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx
index e6bf47d..f28b5ae 100644
--- a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx
+++ b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx
@@ -26,17 +26,7 @@ async function OrderConfirmedPage(props: {
params: Promise<{ orderId: string }>;
}) {
const params = await props.params;
-
- const order = await getAnalysisOrder({
- analysisOrderId: Number(params.orderId),
- }).catch(() => null);
- if (!order) {
- redirect(pathsConfig.app.myOrders);
- }
-
- const medusaOrder = await retrieveOrder(order.medusa_order_id).catch(
- () => null,
- );
+ const medusaOrder = await retrieveOrder(params.orderId).catch(() => null);
if (!medusaOrder) {
redirect(pathsConfig.app.myOrders);
}
@@ -46,7 +36,12 @@ async function OrderConfirmedPage(props: {
} />
-
+
diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx
index 35e9c96..c50b7d1 100644
--- a/app/home/(user)/(dashboard)/order/page.tsx
+++ b/app/home/(user)/(dashboard)/order/page.tsx
@@ -11,12 +11,15 @@ import { PageBody } from '@kit/ui/makerkit/page';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
-import { getAnalysisOrders } from '~/lib/services/order.service';
+import { getAnalysisOrders, getTtoOrders } from '~/lib/services/order.service';
+import { findProductTypeIdByHandle } from '~/lib/utils';
import { listOrders } from '~/medusa/lib/data/orders';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import OrderBlock from '../../_components/orders/order-block';
+const ORDERS_LIMIT = 50;
+
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -26,17 +29,25 @@ export async function generateMetadata() {
}
async function OrdersPage() {
- const medusaOrders = await listOrders();
- const analysisOrders = await getAnalysisOrders();
- const { productTypes } = await listProductTypes();
+ const [medusaOrders, analysisOrders, ttoOrders, { productTypes }] = await Promise.all([
+ listOrders(ORDERS_LIMIT),
+ getAnalysisOrders(),
+ getTtoOrders(),
+ listProductTypes(),
+ ]);
- if (!medusaOrders || !productTypes) {
+ if (!medusaOrders || !productTypes || !ttoOrders) {
redirect(pathsConfig.auth.signIn);
}
- const analysisPackagesType = productTypes.find(
- ({ metadata }) => metadata?.handle === 'analysis-packages',
- )!;
+ const analysisPackagesTypeId = findProductTypeIdByHandle(
+ productTypes,
+ 'analysis-package',
+ );
+ const ttoServiceTypeId = findProductTypeIdByHandle(
+ productTypes,
+ 'tto-service',
+ );
return (
<>
@@ -45,34 +56,45 @@ async function OrdersPage() {
description={
}
/>
- {analysisOrders.map((analysisOrder) => {
- const medusaOrder = medusaOrders.find(
- ({ id }) => id === analysisOrder.medusa_order_id,
+ {medusaOrders.map((medusaOrder) => {
+ const analysisOrder = analysisOrders.find(
+ ({ medusa_order_id }) => medusa_order_id === medusaOrder.id,
);
+
if (!medusaOrder) {
return null;
}
const medusaOrderItems = medusaOrder.items || [];
const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter(
- (item) => item.product_type_id === analysisPackagesType?.id,
+ (item) => item.product_type_id === analysisPackagesTypeId,
+ );
+ const medusaOrderItemsTtoServices = medusaOrderItems.filter(
+ (item) => item.product_type_id === ttoServiceTypeId,
);
const medusaOrderItemsOther = medusaOrderItems.filter(
- (item) => item.product_type_id !== analysisPackagesType?.id,
+ (item) =>
+ !item.product_type_id ||
+ ![analysisPackagesTypeId, ttoServiceTypeId].includes(
+ item.product_type_id,
+ ),
);
return (
-
+
);
})}
- {analysisOrders.length === 0 && (
+ {analysisOrders.length === 0 && ttoOrders.length === 0 && (
diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx
index 22a671c..cdb7bcf 100644
--- a/app/home/(user)/(dashboard)/page.tsx
+++ b/app/home/(user)/(dashboard)/page.tsx
@@ -16,6 +16,7 @@ import Dashboard from '../_components/dashboard';
import DashboardCards from '../_components/dashboard-cards';
import Recommendations from '../_components/recommendations';
import RecommendationsSkeleton from '../_components/recommendations-skeleton';
+import { isValidOpenAiEnv } from '../_lib/server/is-valid-open-ai-env';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
export const generateMetadata = async () => {
@@ -52,17 +53,16 @@ async function UserHomePage() {
/>
- {process.env.OPENAI_API_KEY &&
- process.env.PROMPT_ID_ANALYSIS_RECOMMENDATIONS && (
- <>
-
-
-
- }>
-
-
- >
- )}
+ {(await isValidOpenAiEnv()) && (
+ <>
+
+
+
+ }>
+
+
+ >
+ )}
>
);
diff --git a/app/home/(user)/_components/booking/booking-calendar.tsx b/app/home/(user)/_components/booking/booking-calendar.tsx
new file mode 100644
index 0000000..f8afb0b
--- /dev/null
+++ b/app/home/(user)/_components/booking/booking-calendar.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { isBefore, isSameDay } from 'date-fns';
+import { uniq } from 'lodash';
+
+import { Calendar } from '@kit/ui/shadcn/calendar';
+import { Card } from '@kit/ui/shadcn/card';
+import { cn } from '@kit/ui/utils';
+
+import { useBooking } from './booking.provider';
+
+export default function BookingCalendar() {
+ const { selectedDate, setSelectedDate, isLoadingTimeSlots, timeSlots } =
+ useBooking();
+ const availableDates = uniq(timeSlots?.map((timeSlot) => timeSlot.StartTime));
+
+ return (
+
+ {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ return (
+ isBefore(date, today) ||
+ !availableDates.some((dateWithBooking) =>
+ isSameDay(date, dateWithBooking),
+ )
+ );
+ }}
+ className={cn('rounded-md border', {
+ 'pointer-events-none rounded-md border opacity-50':
+ isLoadingTimeSlots,
+ })}
+ />
+
+ );
+}
diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx
new file mode 100644
index 0000000..9d3b6e8
--- /dev/null
+++ b/app/home/(user)/_components/booking/booking-container.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+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,
+ cartItem,
+ onComplete,
+}: {
+ category: { products: StoreProduct[]; countryCode: string };
+ cartItem?: EnrichedCartItem;
+ onComplete?: () => void;
+}) => {
+ const products = cartItem?.product ? [cartItem.product] : category.products;
+
+ if (!cartItem || !products?.length) {
+
+
+
;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default BookingContainer;
diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts
new file mode 100644
index 0000000..ce59dba
--- /dev/null
+++ b/app/home/(user)/_components/booking/booking.context.ts
@@ -0,0 +1,77 @@
+import { createContext } from 'react';
+
+import { StoreProduct } from '@medusajs/types';
+import { noop } from 'lodash';
+
+import { Tables } from '@kit/supabase/database';
+
+export type Location = Tables<
+ { schema: 'medreport' },
+ 'connected_online_locations'
+>;
+
+export type TimeSlotResponse = {
+ timeSlots: TimeSlot[];
+ locations: Location[];
+};
+
+export type TimeSlot = {
+ ClinicID: number;
+ LocationID: number;
+ UserID: number;
+ SyncUserID: number;
+ ServiceID: number;
+ HKServiceID: number;
+ StartTime: Date;
+ EndTime: Date;
+ PayorCode: string;
+ serviceProvider?: ServiceProvider;
+ syncedService?: SyncedService;
+} & { location?: Location };
+
+export type ServiceProvider = {
+ name: string;
+ id: number;
+ jobTitleEn: string | null;
+ jobTitleEt: string | null;
+ jobTitleRu: string | null;
+ clinicId: number;
+};
+
+export type SyncedService = Tables<
+ { schema: 'medreport' },
+ 'connected_online_services'
+> & {
+ providerClinic: ProviderClinic;
+};
+
+export type ProviderClinic = Tables<
+ { schema: 'medreport' },
+ 'connected_online_providers'
+> & { locations: Location[] };
+
+const BookingContext = createContext<{
+ timeSlots: TimeSlot[] | null;
+ selectedService: StoreProduct | null;
+ locations: Location[] | null;
+ selectedLocationId: number | null;
+ selectedDate?: Date;
+ isLoadingTimeSlots?: boolean;
+ setSelectedService: (selectedService: StoreProduct | null) => void;
+ setSelectedLocationId: (selectedLocationId: number | null) => void;
+ updateTimeSlots: (serviceIds: number[]) => Promise;
+ setSelectedDate: (selectedDate?: Date) => void;
+}>({
+ timeSlots: null,
+ selectedService: null,
+ locations: null,
+ selectedLocationId: null,
+ selectedDate: new Date(),
+ isLoadingTimeSlots: false,
+ setSelectedService: (_) => _,
+ setSelectedLocationId: (_) => _,
+ updateTimeSlots: async (_) => noop(),
+ setSelectedDate: (_) => _,
+});
+
+export { BookingContext };
diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx
new file mode 100644
index 0000000..b0c21e1
--- /dev/null
+++ b/app/home/(user)/_components/booking/booking.provider.tsx
@@ -0,0 +1,80 @@
+import React, { useContext, useEffect, useState } from 'react';
+
+import { StoreProduct } from '@medusajs/types';
+
+import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service';
+
+import { BookingContext, Location, TimeSlot } from './booking.context';
+
+export function useBooking() {
+ const context = useContext(BookingContext);
+
+ if (!context) {
+ throw new Error('useBooking must be used within a BookingProvider.');
+ }
+
+ return context;
+}
+
+export const BookingProvider: React.FC<{
+ children: React.ReactElement;
+ category: { products: StoreProduct[] };
+ service?: StoreProduct;
+}> = ({ children, category, service }) => {
+ const [selectedService, setSelectedService] = useState(
+ (service ?? category?.products?.[0]) || null,
+ );
+ const [selectedLocationId, setSelectedLocationId] = useState(
+ null,
+ );
+ const [selectedDate, setSelectedDate] = useState();
+ const [timeSlots, setTimeSlots] = useState(null);
+ const [locations, setLocations] = useState(null);
+ const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false);
+
+ useEffect(() => {
+ const metadataServiceIds = selectedService?.metadata?.serviceIds as string;
+ if (metadataServiceIds) {
+ const json = JSON.parse(metadataServiceIds);
+ if (Array.isArray(json)) {
+ updateTimeSlots(json);
+ }
+ }
+ }, [selectedService, selectedLocationId]);
+
+ const updateTimeSlots = async (serviceIds: number[]) => {
+ setIsLoadingTimeSlots(true);
+ try {
+ console.log('serviceIds', serviceIds, selectedLocationId);
+ const response = await getAvailableTimeSlotsForDisplay(
+ serviceIds,
+ selectedLocationId,
+ );
+ setTimeSlots(response.timeSlots);
+ setLocations(response.locations);
+ } catch (error) {
+ setTimeSlots(null);
+ } finally {
+ setIsLoadingTimeSlots(false);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/app/home/(user)/_components/booking/location-selector.tsx b/app/home/(user)/_components/booking/location-selector.tsx
new file mode 100644
index 0000000..4cb02aa
--- /dev/null
+++ b/app/home/(user)/_components/booking/location-selector.tsx
@@ -0,0 +1,55 @@
+import { Label } from '@medusajs/ui';
+import { useTranslation } from 'react-i18next';
+
+import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group';
+import { Card } from '@kit/ui/shadcn/card';
+import { Trans } from '@kit/ui/trans';
+
+import { useBooking } from './booking.provider';
+
+const LocationSelector = () => {
+ const { t } = useTranslation();
+ const { selectedLocationId, setSelectedLocationId, locations } = useBooking();
+
+ const onLocationSelect = (locationId: number | string | null) => {
+ if (locationId === 'all') return setSelectedLocationId(null);
+ setSelectedLocationId(Number(locationId));
+ };
+
+ return (
+
+
+
+
+
+
onLocationSelect(val)}
+ >
+
+
+
+
+ {locations?.map((location) => (
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default LocationSelector;
diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx
new file mode 100644
index 0000000..8dd0907
--- /dev/null
+++ b/app/home/(user)/_components/booking/service-selector.tsx
@@ -0,0 +1,85 @@
+import { useState } from 'react';
+
+import { StoreProduct } from '@medusajs/types';
+import { ChevronDown } from 'lucide-react';
+
+import { Card } from '@kit/ui/shadcn/card';
+import { Label } from '@kit/ui/shadcn/label';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@kit/ui/shadcn/popover';
+import { RadioGroup, RadioGroupItem } from '@kit/ui/shadcn/radio-group';
+import { Trans } from '@kit/ui/trans';
+
+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 onServiceSelect = async (productId: StoreProduct['id']) => {
+ const product = products.find((p) => p.id === productId);
+ setSelectedService(product ?? null);
+ setCollapsed(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {firstFourProducts?.map((product) => (
+
+
+
+
+ ))}
+
+ {products.length > 4 && (
+
+ setCollapsed((_) => !_)}
+ className="flex cursor-pointer items-center justify-between border-t py-1"
+ >
+
+
+
+
+
+
+ )}
+
+
+
+ {products?.map((product) => (
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+export default ServiceSelector;
diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx
new file mode 100644
index 0000000..4d5fc14
--- /dev/null
+++ b/app/home/(user)/_components/booking/time-slots.tsx
@@ -0,0 +1,319 @@
+import { useMemo, useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { formatCurrency } from '@/packages/shared/src/utils';
+import { addHours, isAfter, isSameDay } from 'date-fns';
+import { orderBy } from 'lodash';
+import { useTranslation } from 'react-i18next';
+
+import { pathsConfig } from '@kit/shared/config';
+import { formatDateAndTime } from '@kit/shared/utils';
+import { Button } from '@kit/ui/shadcn/button';
+import { Card } from '@kit/ui/shadcn/card';
+import { toast } from '@kit/ui/sonner';
+import { Trans } from '@kit/ui/trans';
+import { cn } from '@kit/ui/utils';
+
+import { updateReservationTime } from '~/lib/services/reservation.service';
+
+import { createInitialReservationAction } from '../../_lib/server/actions';
+import { EnrichedCartItem } from '../cart/types';
+import { ServiceProvider, TimeSlot } from './booking.context';
+import { useBooking } from './booking.provider';
+
+const getServiceProviderTitle = (
+ currentLocale: string,
+ serviceProvider?: ServiceProvider,
+) => {
+ if (!serviceProvider) return null;
+ if (currentLocale === 'en') return serviceProvider.jobTitleEn;
+ if (currentLocale === 'ru') return serviceProvider.jobTitleRu;
+
+ return serviceProvider.jobTitleEt;
+};
+
+const PAGE_SIZE = 7;
+
+const TimeSlots = ({
+ countryCode,
+ cartItem,
+ onComplete,
+}: {
+ countryCode: string;
+ cartItem?: EnrichedCartItem;
+ onComplete?: () => void;
+}) => {
+ const [currentPage, setCurrentPage] = useState(1);
+
+ const {
+ t,
+ i18n: { language: currentLocale },
+ } = useTranslation();
+
+ const booking = useBooking();
+
+ const router = useRouter();
+
+ const selectedDate = booking.selectedDate ?? new Date();
+
+ const filteredBookings = useMemo(
+ () =>
+ orderBy(
+ booking?.timeSlots?.filter(({ StartTime }) => {
+ const firstAvailableTimeToSelect = isSameDay(selectedDate, new Date())
+ ? addHours(new Date(), 0.5)
+ : selectedDate;
+ return isAfter(StartTime, firstAvailableTimeToSelect);
+ }) ?? [],
+ 'StartTime',
+ 'asc',
+ ),
+ [booking.timeSlots, selectedDate],
+ );
+
+ const totalPages = Math.ceil(filteredBookings.length / PAGE_SIZE);
+
+ const paginatedBookings = useMemo(() => {
+ const startIndex = (currentPage - 1) * PAGE_SIZE;
+ const endIndex = startIndex + PAGE_SIZE;
+ return filteredBookings.slice(startIndex, endIndex);
+ }, [filteredBookings, currentPage, PAGE_SIZE]);
+
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page);
+ };
+
+ const generatePageNumbers = () => {
+ const pages = [];
+ const maxVisiblePages = 5;
+
+ if (totalPages <= maxVisiblePages) {
+ for (let i = 1; i <= totalPages; i++) {
+ pages.push(i);
+ }
+ } else {
+ if (currentPage <= 3) {
+ for (let i = 1; i <= 4; i++) {
+ pages.push(i);
+ }
+ pages.push('...');
+ pages.push(totalPages);
+ } else if (currentPage >= totalPages - 2) {
+ pages.push(1);
+ pages.push('...');
+ for (let i = totalPages - 3; i <= totalPages; i++) {
+ pages.push(i);
+ }
+ } else {
+ pages.push(1);
+ pages.push('...');
+ for (let i = currentPage - 1; i <= currentPage + 1; i++) {
+ pages.push(i);
+ }
+ pages.push('...');
+ pages.push(totalPages);
+ }
+ }
+
+ return pages;
+ };
+
+ if (!booking?.timeSlots?.length) {
+ return null;
+ }
+
+ const handleBookTime = async (timeSlot: TimeSlot, comments?: string) => {
+ const selectedService = booking.selectedService;
+
+ const selectedVariant = selectedService?.variants?.[0];
+
+ const syncedService = timeSlot.syncedService;
+ if (!syncedService || !selectedVariant) {
+ return toast.error(t('booking:serviceNotFound'));
+ }
+
+ const bookTimePromise = createInitialReservationAction(
+ selectedVariant,
+ countryCode,
+ Number(syncedService.id),
+ syncedService?.clinic_id,
+ timeSlot.UserID,
+ timeSlot.SyncUserID,
+ timeSlot.StartTime,
+ booking.selectedLocationId ? booking.selectedLocationId : null,
+ comments,
+ ).then(() => {
+ if (onComplete) {
+ onComplete();
+ }
+ router.push(pathsConfig.app.cart);
+ });
+
+ toast.promise(() => bookTimePromise, {
+ success: ,
+ error: ,
+ loading: ,
+ });
+ };
+
+ 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,
+ newStartTime: timeSlot.StartTime,
+ newServiceId: Number(syncedService.id),
+ newAppointmentUserId: timeSlot.UserID,
+ newSyncUserId: timeSlot.SyncUserID,
+ newLocationId: 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 (
+
+
+ {paginatedBookings.map((timeSlot, index) => {
+ const isEHIF = timeSlot.HKServiceID > 0;
+ const serviceProviderTitle = getServiceProviderTitle(
+ currentLocale,
+ timeSlot.serviceProvider,
+ );
+ const price =
+ booking.selectedService?.variants?.[0]?.calculated_price
+ ?.calculated_amount ?? cartItem?.unit_price;
+ return (
+
+
+
{formatDateAndTime(timeSlot.StartTime.toString())}
+
+
+ {timeSlot.serviceProvider?.name}
+
+ {serviceProviderTitle && (
+
+ {serviceProviderTitle}
+
+ )}
+ {isEHIF && {t('booking:ehifBooking')}}
+
+
{timeSlot.location?.address}
+
+
+
+ {formatCurrency({
+ currencyCode: 'EUR',
+ locale: 'et-EE',
+ value: price ?? '',
+ })}
+
+
+
+
+ );
+ })}
+
+ {!paginatedBookings.length && (
+
+
{t('booking:noResults')}
+
+ )}
+
+
+ {totalPages > 1 && (
+
+
+ {t('common:pageOfPages', {
+ page: currentPage,
+ total: totalPages,
+ })}
+
+
+
+
+
+ {generatePageNumbers().map((page, index) => (
+
+ ))}
+
+
+
+
+ )}
+
+ );
+};
+
+export default TimeSlots;
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..eed6fcd
--- /dev/null
+++ b/app/home/(user)/_components/cart/cart-service-item.tsx
@@ -0,0 +1,146 @@
+'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,
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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..ad5cc12
--- /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 7887040..5f884a5 100644
--- a/app/home/(user)/_components/cart/index.tsx
+++ b/app/home/(user)/_components/cart/index.tsx
@@ -2,9 +2,7 @@
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';
@@ -15,28 +13,41 @@ 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 { initiatePayment } from '../../_lib/server/cart-actions';
+import { useRouter } from 'next/navigation';
+import { AccountBalanceSummary } from '@kit/accounts/services/account-balance.service';
+import { EnrichedCartItem } from './types';
const IS_DISCOUNT_SHOWN = true as boolean;
export default function Cart({
+ accountId,
cart,
synlabAnalyses,
ttoServiceItems,
+ balanceSummary,
}: {
+ accountId: string;
cart: StoreCart | null;
synlabAnalyses: StoreCartLineItem[];
- ttoServiceItems: StoreCartLineItem[];
+ ttoServiceItems: EnrichedCartItem[];
+ balanceSummary: AccountBalanceSummary | null;
}) {
const {
i18n: { language },
} = useTranslation();
const [isInitiatingSession, setIsInitiatingSession] = useState(false);
+ const router = useRouter();
+ const [unavailableLineItemIds, setUnavailableLineItemIds] =
+ useState();
const items = cart?.items ?? [];
+ const hasCartItems = cart && Array.isArray(items) && items.length > 0;
- if (!cart || items.length === 0) {
+ if (!hasCartItems) {
return (
@@ -56,24 +67,38 @@ export default function Cart({
);
}
- async function initiatePayment() {
+ async function initiateSession() {
setIsInitiatingSession(true);
- const response = await initiatePaymentSession(cart!, {
- provider_id: 'pp_montonio_montonio',
- });
- 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;
- } else {
+
+ try {
+ const { url, isFullyPaidByBenefits, orderId, unavailableLineItemIds } = await initiatePayment({
+ accountId,
+ balanceSummary: balanceSummary!,
+ cart: cart!,
+ language,
+ });
+ if (unavailableLineItemIds) {
+ setUnavailableLineItemIds(unavailableLineItemIds);
+ }
+ if (url) {
+ window.location.href = url;
+ } else if (isFullyPaidByBenefits) {
+ if (typeof orderId !== 'number') {
+ throw new Error('Order ID is missing');
+ }
+ router.push(`/home/order/${orderId}/confirmed`);
+ }
+ } catch (error) {
+ console.error('Failed to initiate payment', error);
setIsInitiatingSession(false);
}
}
- const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0;
const isLocationsShown = synlabAnalyses.length > 0;
+ const companyBenefitsTotal = balanceSummary?.totalBalance ?? 0;
+ const montonioTotal = cart && companyBenefitsTotal > 0 ? cart.total - companyBenefitsTotal : cart.total;
+
return (
@@ -82,10 +107,11 @@ export default function Cart({
items={synlabAnalyses}
productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel"
/>
-
{hasCartItems && (
@@ -106,7 +132,7 @@ export default function Cart({
-
+
@@ -122,6 +148,24 @@ export default function Cart({
+ {companyBenefitsTotal > 0 && (
+
+
+
+
+ {formatCurrency({
+ value: (companyBenefitsTotal > cart.total ? cart.total : companyBenefitsTotal),
+ currencyCode: cart.currency_code,
+ locale: language,
+ })}
+
+
+
+ )}
@@ -131,7 +175,7 @@ export default function Cart({
{formatCurrency({
- value: cart.total,
+ value: montonioTotal < 0 ? 0 : montonioTotal,
currencyCode: cart.currency_code,
locale: language,
})}
@@ -175,7 +219,7 @@ export default function Cart({