diff --git a/app/admin/_components/admin-menu-navigation.tsx b/app/admin/_components/admin-menu-navigation.tsx index 7a6185d..6fd2d7d 100644 --- a/app/admin/_components/admin-menu-navigation.tsx +++ b/app/admin/_components/admin-menu-navigation.tsx @@ -1,12 +1,11 @@ +import { SIDEBAR_WIDTH_PROPERTY } from '@/packages/ui/src/shadcn/constants'; + import { AppLogo } from '@kit/shared/components/app-logo'; import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container'; -import { SIDEBAR_WIDTH_PROPERTY } from '@/packages/ui/src/shadcn/constants'; import type { UserWorkspace } from '../../home/(user)/_lib/server/load-user-workspace'; -export function AdminMenuNavigation(props: { - workspace: UserWorkspace; -}) { +export function AdminMenuNavigation(props: { workspace: UserWorkspace }) { const { accounts } = props.workspace; return ( @@ -17,9 +16,7 @@ export function AdminMenuNavigation(props: {
- +
diff --git a/app/admin/accounts/[id]/page.tsx b/app/admin/accounts/[id]/page.tsx index f386f17..0048bcf 100644 --- a/app/admin/accounts/[id]/page.tsx +++ b/app/admin/accounts/[id]/page.tsx @@ -3,8 +3,8 @@ import { cache } from 'react'; import { AdminAccountPage } from '@kit/admin/components/admin-account-page'; import { AdminGuard } from '@kit/admin/components/admin-guard'; -import { getAccount } from '~/lib/services/account.service'; import { withI18n } from '~/lib/i18n/with-i18n'; +import { getAccount } from '~/lib/services/account.service'; interface Params { params: Promise<{ diff --git a/app/admin/accounts/page.tsx b/app/admin/accounts/page.tsx index 93e1ffd..5f193da 100644 --- a/app/admin/accounts/page.tsx +++ b/app/admin/accounts/page.tsx @@ -8,6 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; import { Button } from '@kit/ui/button'; import { PageBody, PageHeader } from '@kit/ui/page'; + import { withI18n } from '~/lib/i18n/with-i18n'; interface SearchParams { diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 43bd5e1..f760733 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -5,8 +5,8 @@ import { cookies } from 'next/headers'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; -import { AdminSidebar } from '~/admin/_components/admin-sidebar'; import { AdminMenuNavigation } from '~/admin/_components/admin-menu-navigation'; +import { AdminSidebar } from '~/admin/_components/admin-sidebar'; import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation'; import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace'; diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 07340a8..d3040a7 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,6 +1,7 @@ import { AdminDashboard } from '@kit/admin/components/admin-dashboard'; import { AdminGuard } from '@kit/admin/components/admin-guard'; import { PageBody, PageHeader } from '@kit/ui/page'; + import { withI18n } from '~/lib/i18n/with-i18n'; function AdminPage() { diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 39a5fb3..07b7435 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -2,7 +2,40 @@ import axios from 'axios'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import type { ISearchLoadResponse } from '~/lib/types/connected-online'; +import { logSyncResult } from '~/lib/services/audit.service'; +import { SyncStatus } from '~/lib/types/audit'; +import type { + ISearchLoadResponse, + P_JobTitleTranslation, +} from '~/lib/types/connected-online'; + +function createTranslationMap(translations: P_JobTitleTranslation[]) { + const result: Map< + number, + Map + > = new Map(); + + for (const translation of translations) { + const { ClinicID, TextET, TextEN, TextRU, SyncID } = translation; + + if (!result.has(ClinicID)) { + result.set(ClinicID, new Map()); + } + + result.get(ClinicID)!.set(SyncID, { + textET: TextET, + textEN: TextEN, + textRU: TextRU, + }); + } + + return result; +} + +function getSpokenLanguages(spokenLanguages?: string) { + if (!spokenLanguages || !spokenLanguages.length) return []; + return spokenLanguages.split(','); +} export default async function syncConnectedOnline() { const isProd = process.env.NODE_ENV === 'production'; @@ -16,14 +49,19 @@ export default async function syncConnectedOnline() { const supabase = getSupabaseServerAdminClient(); try { - const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, { - headers: { - 'Content-Type': 'application/json; charset=utf-8', + const searchLoadResponse = await axios.post<{ d: string }>( + `${baseUrl}/Search_Load`, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + param: "{'Value':'|et|-1'}", // get all available services in Estonian }, - param: "{'Value':'|et|-1'}", // get all available services in Estonian - }); + ); - const responseData: ISearchLoadResponse = JSON.parse(response.data.d); + const responseData: ISearchLoadResponse = JSON.parse( + searchLoadResponse.data.d, + ); if (responseData?.ErrorCode !== 0) { throw new Error('Failed to get Connected Online data'); @@ -43,16 +81,35 @@ export default async function syncConnectedOnline() { let clinics; let services; + let serviceProviders; + let jobTitleTranslations; // Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment + const isDemoClinic = (clinicId: number) => clinicId === 2; if (isProd) { - clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID !== 2); + clinics = responseData.Data.T_Lic.filter(({ ID }) => !isDemoClinic(ID)); services = responseData.Data.T_Service.filter( - (service) => service.ClinicID !== 2, + ({ ClinicID }) => !isDemoClinic(ClinicID), + ); + serviceProviders = responseData.Data.T_Doctor.filter( + ({ ClinicID }) => !isDemoClinic(ClinicID), + ); + jobTitleTranslations = createTranslationMap( + responseData.Data.P_JobTitleTranslations.filter( + ({ ClinicID }) => !isDemoClinic(ClinicID), + ), ); } else { - clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID === 2); - services = responseData.Data.T_Service.filter( - (service) => service.ClinicID === 2, + clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID)); + services = responseData.Data.T_Service.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + jobTitleTranslations = createTranslationMap( + responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ), ); } @@ -64,6 +121,8 @@ export default async function syncConnectedOnline() { name: clinic.Name, personal_code_required: !!clinic.PersonalCodeRequired, phone_number: clinic.Phone || null, + key: clinic.Key, + address: clinic.Address, }; }); @@ -71,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, @@ -87,45 +146,133 @@ export default async function syncConnectedOnline() { }; }); + const mappedServiceProviders = serviceProviders.map((serviceProvider) => { + const jobTitleTranslation = serviceProvider.JobTitleID + ? jobTitleTranslations + .get(serviceProvider.ClinicID) + ?.get(serviceProvider.JobTitleID) + : null; + return { + id: serviceProvider.ID, + prefix: serviceProvider.Prefix, + name: serviceProvider.Name, + spoken_languages: getSpokenLanguages(serviceProvider.SpokenLanguages), + job_title_et: jobTitleTranslation?.textET, + job_title_en: jobTitleTranslation?.textEN, + job_title_ru: jobTitleTranslation?.textRU, + job_title_id: serviceProvider.JobTitleID, + is_deleted: !!serviceProvider.Deleted, + clinic_id: serviceProvider.ClinicID, + }; + }); + const { error: providersError } = await supabase .schema('medreport') .from('connected_online_providers') .upsert(mappedClinics); + if (providersError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving connected online providers: ' + + JSON.stringify(providersError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); + } + const { error: servicesError } = await supabase .schema('medreport') .from('connected_online_services') - .upsert(mappedServices, { onConflict: 'id', ignoreDuplicates: false }); + .upsert(mappedServices, { + onConflict: 'id', + ignoreDuplicates: false, + }); - if (providersError || servicesError) { - return supabase - .schema('audit') - .from('sync_entries') - .insert({ - operation: 'CONNECTED_ONLINE_SYNC', - comment: providersError - ? 'Error saving providers: ' + JSON.stringify(providersError) - : 'Error saving services: ' + JSON.stringify(servicesError), - status: 'FAIL', - changed_by_role: 'service_role', - }); + if (servicesError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving connected online services: ' + + JSON.stringify(servicesError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); } - await supabase.schema('audit').from('sync_entries').insert({ + const { error: serviceProvidersError } = await supabase + .schema('medreport') + .from('connected_online_service_providers') + .upsert(mappedServiceProviders, { + onConflict: 'id', + ignoreDuplicates: false, + }); + + if (serviceProvidersError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving service providers: ' + + JSON.stringify(serviceProvidersError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); + } + + for (const mappedClinic of mappedClinics) { + const defaultLoadResponse = await axios.post<{ d: string }>( + `${baseUrl}/Default_Load`, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + param: `{'Value':'${mappedClinic.key}|et'}`, + }, + ); + + const defaultLoadResponseData = JSON.parse(defaultLoadResponse.data.d); + + if (defaultLoadResponseData?.ErrorCode !== 0) { + throw new Error('Failed to get Connected Online location data'); + } + + const clinicLocations: { + SyncID: number; + Address: string; + Name: string; + }[] = defaultLoadResponseData.Data.T_SelectableLocation; + + if (clinicLocations?.length) { + const mappedLocations = clinicLocations.map( + ({ SyncID, Address, Name }) => ({ + address: Address, + clinic_id: mappedClinic.id, + sync_id: SyncID, + name: Name, + }), + ); + + await supabase + .schema('medreport') + .from('connected_online_locations') + .insert(mappedLocations) + .throwOnError(); + } + } + + await logSyncResult({ operation: 'CONNECTED_ONLINE_SYNC', - status: 'SUCCESS', + status: SyncStatus.Success, changed_by_role: 'service_role', }); } catch (e) { - await supabase - .schema('audit') - .from('sync_entries') - .insert({ - operation: 'CONNECTED_ONLINE_SYNC', - status: 'FAIL', - comment: JSON.stringify(e), - changed_by_role: 'service_role', - }); + await logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + status: SyncStatus.Fail, + comment: JSON.stringify(e), + changed_by_role: 'service_role', + }); throw new Error( `Failed to sync Connected Online data, error: ${JSON.stringify(e)}`, ); diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index ebce187..1de06a7 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -1,12 +1,20 @@ +import { redirect } from 'next/navigation'; + import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header'; import { loadCategory } from '@/app/home/(user)/_lib/server/load-category'; +import { pathsConfig } from '@kit/shared/config'; import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs'; -import { PageBody } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; +import BookingContainer from '~/home/(user)/_components/booking/booking-container'; +import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; +import { + PageViewAction, + createPageViewLog, +} from '~/lib/services/audit/pageView.service'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); @@ -17,9 +25,30 @@ export const generateMetadata = async () => { }; }; -async function BookingHandlePage({ params }: { params: { handle: string } }) { - const handle = await params.handle; +async function BookingHandlePage({ + params, +}: { + params: Promise<{ handle: string }>; +}) { + const { handle } = await params; const { category } = await loadCategory({ handle }); + const { account } = await loadCurrentUserAccount(); + + if (!category) { + return
Category not found
; + } + + if (!account) { + return redirect(pathsConfig.auth.signIn); + } + + await createPageViewLog({ + accountId: account.id, + action: PageViewAction.VIEW_TTO_SERVICE_BOOKING, + extraData: { + handle, + }, + }); return ( <> @@ -30,10 +59,10 @@ async function BookingHandlePage({ params }: { params: { handle: string } }) { /> } - description={} + description="" /> - + ); } diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index fd3c894..b1ccb05 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -8,17 +8,20 @@ import type { StoreOrder } from '@medusajs/types'; import jwt from 'jsonwebtoken'; import { z } from 'zod'; -import type { AccountWithParams } from '@kit/accounts/types/accounts'; +import { AccountWithParams } from '@kit/accounts/types/accounts'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { bookAppointment } from '~/lib/services/connected-online.service'; import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; import { createAnalysisOrder, getAnalysisOrder, } from '~/lib/services/order.service'; +import { getOrderedTtoServices } from '~/lib/services/reservation.service'; +import { FailureReason } from '~/lib/types/connected-online'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; @@ -91,6 +94,10 @@ const sendEmail = async ({ account_id: account.id, body: html, }); + await createNotificationsApi(client).createNotification({ + account_id: account.id, + body: html, + }); } catch (error) { throw new Error(`Failed to send email, message=${error}`); } @@ -183,10 +190,7 @@ async function sendAnalysisPackageOrderEmail({ }); console.info(`Successfully sent analysis package order email to ${email}`); } catch (error) { - console.error( - `Failed to send analysis package order email to ${email}`, - error, - ); + console.error('Failed to send email', error); } } @@ -207,6 +211,8 @@ export async function processMontonioCallback(orderToken: string) { medusaOrder, }); + const orderContainsSynlabItems = !!orderedAnalysisElements?.length; + try { const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id, @@ -219,15 +225,38 @@ export async function processMontonioCallback(orderToken: string) { // ignored } - const orderId = await createAnalysisOrder({ - medusaOrder, - orderedAnalysisElements, - }); + let orderId; + if (orderContainsSynlabItems) { + orderId = await createAnalysisOrder({ + medusaOrder, + orderedAnalysisElements, + }); + } + const orderResult = await getOrderResultParameters(medusaOrder); const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; + const orderedTtoServices = await getOrderedTtoServices({ medusaOrder }); + let bookServiceResults: { + success: boolean; + reason?: FailureReason; + serviceId?: number; + }[] = []; + if (orderedTtoServices?.length) { + const bookingPromises = orderedTtoServices.map((service) => + bookAppointment( + service.service_id, + service.clinic_id, + service.service_user_id, + service.sync_user_id, + service.start_time, + ), + ); + bookServiceResults = await Promise.all(bookingPromises); + } + if (email) { if (analysisPackageOrder) { await sendAnalysisPackageOrderEmail({ @@ -251,10 +280,21 @@ export async function processMontonioCallback(orderToken: string) { console.error('Missing email to send order result email', orderResult); } - if (env().isEnabledDispatchOnMontonioCallback) { + if (env().isEnabledDispatchOnMontonioCallback && orderContainsSynlabItems) { await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); } + if (bookServiceResults.some(({ success }) => success === false)) { + const failedServiceBookings = bookServiceResults.filter( + ({ success }) => success === false, + ); + return { + success: false, + failedServiceBookings, + orderId, + }; + } + return { success: true, orderId }; } catch (error) { console.error('Failed to place order', error); diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx index 73abd3f..76042e1 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx @@ -34,8 +34,15 @@ export default function MontonioCallbackClient({ setHasProcessed(true); try { - const { orderId } = await processMontonioCallback(orderToken); - router.push(`/home/order/${orderId}/confirmed`); + const result = await processMontonioCallback(orderToken); + if (result.success) { + return router.push(`/home/order/${result.orderId}/confirmed`); + } + if (result.failedServiceBookings?.length) { + router.push( + `/home/cart/montonio-callback/error?reasonFailed=${result.failedServiceBookings.map(({ reason }) => reason).join(',')}`, + ); + } } catch (error) { console.error('Failed to place order', error); router.push('/home/cart/montonio-callback/error'); diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx index 6c08efb..c84908a 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx @@ -1,8 +1,11 @@ +import { use } from 'react'; + import Link from 'next/link'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; +import { toArray } from '@kit/shared/utils'; import { Button } from '@kit/ui/button'; import { Alert, AlertDescription } from '@kit/ui/shadcn/alert'; import { AlertTitle } from '@kit/ui/shadcn/alert'; @@ -16,7 +19,15 @@ export async function generateMetadata() { }; } -export default async function MontonioCheckoutCallbackErrorPage() { +export default async function MontonioCheckoutCallbackErrorPage({ + searchParams, +}: { + searchParams: Promise<{ reasonFailed: string }>; +}) { + const params = await searchParams; + + const failedBookingData: string[] = toArray(params.reasonFailed?.split(',')); + return (
} /> @@ -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..4a2af1f 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -8,9 +8,12 @@ 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 { EnrichedCartItem } from '../../_components/cart/types'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -27,29 +30,33 @@ async function CartPage() { }); const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find( - ({ metadata }) => metadata?.handle === 'analysis-packages', + + 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, ); diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index 35e9c96..2e9fe68 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -11,7 +11,8 @@ 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'; @@ -28,15 +29,21 @@ export async function generateMetadata() { async function OrdersPage() { const medusaOrders = await listOrders(); const analysisOrders = await getAnalysisOrders(); + const ttoOrders = await getTtoOrders(); const { productTypes } = await 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,9 +52,9 @@ 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; @@ -55,18 +62,27 @@ async function OrdersPage() { 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 ( - + 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..220a79f --- /dev/null +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -0,0 +1,317 @@ +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, + 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 ( +
+
+ {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 ( + + + + + + + + + + +
+ {item.product && item.reservation.countryCode ? ( + + ) : ( +

+ +

+ )} +
+
+
+ ); +}; + +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..81fbdd4 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -15,7 +15,9 @@ 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; @@ -26,13 +28,15 @@ export default function Cart({ }: { cart: StoreCart | null; synlabAnalyses: StoreCartLineItem[]; - ttoServiceItems: StoreCartLineItem[]; + ttoServiceItems: EnrichedCartItem[]; }) { const { i18n: { language }, } = useTranslation(); const [isInitiatingSession, setIsInitiatingSession] = useState(false); + const [unavailableLineItemIds, setUnavailableLineItemIds] = + useState(); const items = cart?.items ?? []; @@ -64,8 +68,16 @@ 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); } @@ -82,10 +94,11 @@ export default function Cart({ items={synlabAnalyses} productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel" /> -
{hasCartItems && ( @@ -167,6 +180,10 @@ export default function Cart({ cart={{ ...cart }} synlabAnalyses={synlabAnalyses} /> + )} diff --git a/app/home/(user)/_components/cart/types.ts b/app/home/(user)/_components/cart/types.ts index 9150557..aa5cc6d 100644 --- a/app/home/(user)/_components/cart/types.ts +++ b/app/home/(user)/_components/cart/types.ts @@ -1,3 +1,6 @@ +import { StoreCartLineItem } from "@medusajs/types"; +import { Reservation } from "~/lib/types/reservation"; + export interface MontonioOrderToken { uuid: string; accessKey: string; @@ -10,6 +13,12 @@ export interface MontonioOrderToken { | 'PENDING' | 'EXPIRED' | 'REFUNDED'; + | 'PAID' + | 'FAILED' + | 'CANCELLED' + | 'PENDING' + | 'EXPIRED' + | 'REFUNDED'; paymentMethod: string; grandTotal: number; currency: string; @@ -20,3 +29,10 @@ export interface MontonioOrderToken { iat: number; exp: number; } + +export enum CartItemType { + analysisOrders = 'analysisOrders', + ttoServices = 'ttoServices', +} + +export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation }; diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx index 8d6a04d..88574fa 100644 --- a/app/home/(user)/_components/order-analyses-cards.tsx +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -103,7 +103,6 @@ export default function OrderAnalysesCards({ {title} {description && ( <> - {' '} diff --git a/app/home/(user)/_components/orders/order-block.tsx b/app/home/(user)/_components/orders/order-block.tsx index 03f0447..4071b0f 100644 --- a/app/home/(user)/_components/orders/order-block.tsx +++ b/app/home/(user)/_components/orders/order-block.tsx @@ -12,40 +12,54 @@ import OrderItemsTable from './order-items-table'; export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, + itemsTtoService, itemsOther, + medusaOrderId, }: { - analysisOrder: AnalysisOrder; + analysisOrder?: AnalysisOrder; itemsAnalysisPackage: StoreOrderLineItem[]; + itemsTtoService: StoreOrderLineItem[]; itemsOther: StoreOrderLineItem[]; + medusaOrderId: string; }) { return (

- {` (${analysisOrder.id})`}

-
-
- -
- - - -
+ {analysisOrder && ( +
+
+ +
+ + + +
+ )}
- + {analysisOrder && ( + + )} + {itemsTtoService && ( + + )} { - await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); - router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + if (analysisOrder) { + await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); + router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + } }; return ( @@ -55,7 +62,7 @@ export default function OrderItemsTable({ - + {isAnalysisOrder && } @@ -76,14 +83,18 @@ export default function OrderItemsTable({ - + - - - + {isAnalysisOrder && ( + + + + )} ))} diff --git a/app/home/(user)/_components/service-categories.tsx b/app/home/(user)/_components/service-categories.tsx index 9ef3e25..6998b78 100644 --- a/app/home/(user)/_components/service-categories.tsx +++ b/app/home/(user)/_components/service-categories.tsx @@ -4,17 +4,20 @@ import React from 'react'; import { redirect } from 'next/navigation'; -import { createPath, pathsConfig } from '@/packages/shared/src/config'; +import { pathsConfig } from '@/packages/shared/src/config'; +import { StoreProduct } from '@medusajs/types'; import { ComponentInstanceIcon } from '@radix-ui/react-icons'; import { cn } from '@kit/ui/shadcn'; -import { Card, CardDescription, CardTitle } from '@kit/ui/shadcn/card'; +import { Card, CardDescription } from '@kit/ui/shadcn/card'; export interface ServiceCategory { name: string; handle: string; color: string; description: string; + products: StoreProduct[]; + countryCode: string; } const ServiceCategories = ({ diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts new file mode 100644 index 0000000..43d90b8 --- /dev/null +++ b/app/home/(user)/_lib/server/actions.ts @@ -0,0 +1,43 @@ +'use server'; + +import { updateLineItem } from '@lib/data/cart'; +import { StoreProductVariant } from '@medusajs/types'; + +import { handleAddToCart } from '~/lib/services/medusaCart.service'; +import { createInitialReservation } from '~/lib/services/reservation.service'; + +export async function createInitialReservationAction( + selectedVariant: Pick, + countryCode: string, + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserId: number, + startTime: Date, + locationId: number | null, + comments?: string, +) { + const { addedItem } = await handleAddToCart({ + selectedVariant, + countryCode, + }); + + if (addedItem) { + const reservation = await createInitialReservation( + serviceId, + clinicId, + appointmentUserId, + syncUserId, + startTime, + addedItem.id, + locationId, + comments, + ); + + await updateLineItem({ + lineId: addedItem.id, + quantity: addedItem.quantity, + metadata: { connectedOnlineReservationId: reservation.id }, + }); + } +} diff --git a/app/home/(user)/_lib/server/load-analyses.ts b/app/home/(user)/_lib/server/load-analyses.ts index 411d68a..8b9cf99 100644 --- a/app/home/(user)/_lib/server/load-analyses.ts +++ b/app/home/(user)/_lib/server/load-analyses.ts @@ -45,10 +45,6 @@ async function analysesLoader() { }) : null; - const serviceCategories = productCategories.filter( - ({ parent_category }) => parent_category?.handle === 'tto-categories', - ); - return { analyses: categoryProducts?.response.products diff --git a/app/home/(user)/_lib/server/load-category.ts b/app/home/(user)/_lib/server/load-category.ts index 2c0479c..72e22d4 100644 --- a/app/home/(user)/_lib/server/load-category.ts +++ b/app/home/(user)/_lib/server/load-category.ts @@ -1,20 +1,30 @@ import { cache } from 'react'; -import { getProductCategories } from '@lib/data'; +import { getProductCategories, listProducts } from '@lib/data'; -import { ServiceCategory } from '../../_components/service-categories'; - -async function categoryLoader({ - handle, -}: { - handle: string; -}): Promise<{ category: ServiceCategory | null }> { - const response = await getProductCategories({ - handle, - fields: '*products, is_active, metadata', - }); +import { loadCountryCodes } from './load-analyses'; +async function categoryLoader({ handle }: { handle: string }) { + const [response, countryCodes] = await Promise.all([ + getProductCategories({ + handle, + limit: 1, + }), + loadCountryCodes(), + ]); const category = response.product_categories[0]; + const countryCode = countryCodes[0]!; + + if (!response.product_categories?.[0]?.id) { + return { category: null }; + } + + const { + response: { products: categoryProducts }, + } = await listProducts({ + countryCode, + queryParams: { limit: 100, category_id: response.product_categories[0].id }, + }); return { category: { @@ -25,6 +35,8 @@ async function categoryLoader({ description: category?.description || '', handle: category?.handle || '', name: category?.name || '', + countryCode, + products: categoryProducts, }, }; } diff --git a/app/home/(user)/_lib/server/load-tto-services.ts b/app/home/(user)/_lib/server/load-tto-services.ts index 3bbc4e5..530f959 100644 --- a/app/home/(user)/_lib/server/load-tto-services.ts +++ b/app/home/(user)/_lib/server/load-tto-services.ts @@ -10,38 +10,36 @@ async function ttoServicesLoader() { }); const heroCategories = response.product_categories?.filter( - ({ parent_category, is_active, metadata }) => - parent_category?.handle === 'tto-categories' && - is_active && - metadata?.isHero, + ({ parent_category, metadata }) => + parent_category?.handle === 'tto-categories' && metadata?.isHero, ); const ttoCategories = response.product_categories?.filter( - ({ parent_category, is_active, metadata }) => - parent_category?.handle === 'tto-categories' && - is_active && - !metadata?.isHero, + ({ parent_category, metadata }) => + parent_category?.handle === 'tto-categories' && !metadata?.isHero, ); return { heroCategories: - heroCategories.map( - ({ name, handle, metadata, description }) => ({ + heroCategories.map>( + ({ name, handle, metadata, description, products }) => ({ name, handle, color: typeof metadata?.color === 'string' ? metadata.color : 'primary', description, + products: products ?? [], }), ) ?? [], ttoCategories: - ttoCategories.map( - ({ name, handle, metadata, description }) => ({ + ttoCategories.map>( + ({ name, handle, metadata, description, products }) => ({ name, handle, color: typeof metadata?.color === 'string' ? metadata.color : 'primary', description, + products: products ?? [], }), ) ?? [], }; diff --git a/app/home/[account]/_components/team-account-layout-sidebar.tsx b/app/home/[account]/_components/team-account-layout-sidebar.tsx index f2c9626..67cf05b 100644 --- a/app/home/[account]/_components/team-account-layout-sidebar.tsx +++ b/app/home/[account]/_components/team-account-layout-sidebar.tsx @@ -52,7 +52,6 @@ function SidebarContainer(props: { - ); } diff --git a/app/home/[account]/billing/_components/health-benefit-fields.tsx b/app/home/[account]/billing/_components/health-benefit-fields.tsx index 55c533c..a3947de 100644 --- a/app/home/[account]/billing/_components/health-benefit-fields.tsx +++ b/app/home/[account]/billing/_components/health-benefit-fields.tsx @@ -30,7 +30,9 @@ const HealthBenefitFields = () => {