diff --git a/.env.development b/.env.development index 2cc0b56..4fdb0cc 100644 --- a/.env.development +++ b/.env.development @@ -43,6 +43,7 @@ MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true #MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false # MEDUSA +COMPANY_BENEFITS_PAYMENT_SECRET_KEY=NzcwMzE2NmEtOThiMS0xMWYwLWI4NjYtMDMwZDQzMjFhMjExCg== MEDUSA_BACKEND_URL=http://localhost:9000 MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000 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..80e89c9 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -2,10 +2,45 @@ 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'; + const isProd = !['test', 'localhost'].some((pathString) => + process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString), + ); const baseUrl = process.env.CONNECTED_ONLINE_URL; @@ -16,14 +51,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,18 +83,23 @@ 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 - if (isProd) { - clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID !== 2); - services = responseData.Data.T_Service.filter( - (service) => service.ClinicID !== 2, - ); - } else { - clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID === 2); - services = responseData.Data.T_Service.filter( - (service) => service.ClinicID === 2, - ); - } + const isDemoClinic = (clinicId: number) => + isProd ? clinicId !== 2 : 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), + ), + ); const mappedClinics = clinics.map((clinic) => { return { @@ -64,6 +109,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 +118,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 +134,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..a23660c 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -2,100 +2,13 @@ import { MontonioOrderToken } from '@/app/home/(user)/_components/cart/types'; import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; -import { placeOrder, retrieveCart } from '@lib/data/cart'; -import { listProductTypes } from '@lib/data/products'; -import type { StoreOrder } from '@medusajs/types'; +import { retrieveCart } from '@lib/data/cart'; import jwt from 'jsonwebtoken'; -import { z } from 'zod'; -import type { AccountWithParams } from '@kit/accounts/types/accounts'; -import { createNotificationsApi } from '@kit/notifications/api'; -import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +import { handlePlaceOrder } from '../../../_lib/server/cart-actions'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; -import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; -import { - createAnalysisOrder, - getAnalysisOrder, -} from '~/lib/services/order.service'; - -const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; -const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; const MONTONIO_PAID_STATUS = 'PAID'; -const env = () => - z - .object({ - emailSender: z - .string({ - error: 'EMAIL_SENDER is required', - }) - .min(1), - siteUrl: z - .string({ - error: 'NEXT_PUBLIC_SITE_URL is required', - }) - .min(1), - isEnabledDispatchOnMontonioCallback: z.boolean({ - error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required', - }), - }) - .parse({ - emailSender: process.env.EMAIL_SENDER, - siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, - isEnabledDispatchOnMontonioCallback: - process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', - }); - -const sendEmail = async ({ - account, - email, - analysisPackageName, - partnerLocationName, - language, -}: { - account: Pick; - email: string; - analysisPackageName: string; - partnerLocationName: string; - language: string; -}) => { - const client = getSupabaseServerAdminClient(); - try { - const { renderSynlabAnalysisPackageEmail } = await import( - '@kit/email-templates' - ); - const { getMailer } = await import('@kit/mailers'); - - const mailer = await getMailer(); - - const { html, subject } = await renderSynlabAnalysisPackageEmail({ - analysisPackageName, - personName: account.name, - partnerLocationName, - language, - }); - - await mailer - .sendEmail({ - from: env().emailSender, - to: email, - subject, - html, - }) - .catch((error) => { - throw new Error(`Failed to send email, message=${error}`); - }); - await createNotificationsApi(client).createNotification({ - account_id: account.id, - body: html, - }); - } catch (error) { - throw new Error(`Failed to send email, message=${error}`); - } -}; - async function decodeOrderToken(orderToken: string) { const secretKey = process.env.MONTONIO_SECRET_KEY as string; @@ -122,74 +35,6 @@ async function getCartByOrderToken(decoded: MontonioOrderToken) { return cart; } -async function getOrderResultParameters(medusaOrder: StoreOrder) { - const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find( - ({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE, - ); - const analysisType = productTypes.find( - ({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE, - ); - - const analysisPackageOrderItem = medusaOrder.items?.find( - ({ product_type_id }) => product_type_id === analysisPackagesType?.id, - ); - const analysisItems = medusaOrder.items?.filter( - ({ product_type_id }) => product_type_id === analysisType?.id, - ); - - return { - medusaOrderId: medusaOrder.id, - email: medusaOrder.email, - analysisPackageOrder: analysisPackageOrderItem - ? { - partnerLocationName: - (analysisPackageOrderItem?.metadata - ?.partner_location_name as string) ?? '', - analysisPackageName: analysisPackageOrderItem?.title ?? '', - } - : null, - analysisItemsOrder: - Array.isArray(analysisItems) && analysisItems.length > 0 - ? analysisItems.map(({ product }) => ({ - analysisName: product?.title ?? '', - analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', - })) - : null, - }; -} - -async function sendAnalysisPackageOrderEmail({ - account, - email, - analysisPackageOrder, -}: { - account: AccountWithParams; - email: string; - analysisPackageOrder: { - partnerLocationName: string; - analysisPackageName: string; - }; -}) { - const { language } = await createI18nServerInstance(); - const { analysisPackageName, partnerLocationName } = analysisPackageOrder; - try { - await sendEmail({ - account: { id: account.id, name: account.name }, - email, - analysisPackageName, - partnerLocationName, - language, - }); - console.info(`Successfully sent analysis package order email to ${email}`); - } catch (error) { - console.error( - `Failed to send analysis package order email to ${email}`, - error, - ); - } -} - export async function processMontonioCallback(orderToken: string) { const { account } = await loadCurrentUserAccount(); if (!account) { @@ -199,63 +44,8 @@ export async function processMontonioCallback(orderToken: string) { try { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - - const medusaOrder = await placeOrder(cart.id, { - revalidateCacheTags: false, - }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ - medusaOrder, - }); - - try { - const existingAnalysisOrder = await getAnalysisOrder({ - medusaOrderId: medusaOrder.id, - }); - console.info( - `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, - ); - return { success: true, orderId: existingAnalysisOrder.id }; - } catch { - // ignored - } - - const orderId = await createAnalysisOrder({ - medusaOrder, - orderedAnalysisElements, - }); - const orderResult = await getOrderResultParameters(medusaOrder); - - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = - orderResult; - - if (email) { - if (analysisPackageOrder) { - await sendAnalysisPackageOrderEmail({ - account, - email, - analysisPackageOrder, - }); - } else { - console.info(`Order has no analysis package, skipping email.`); - } - - if (analysisItemsOrder) { - // @TODO send email for separate analyses - console.warn( - `Order has analysis items, but no email template exists yet`, - ); - } else { - console.info(`Order has no analysis items, skipping email.`); - } - } else { - console.error('Missing email to send order result email', orderResult); - } - - if (env().isEnabledDispatchOnMontonioCallback) { - await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); - } - - return { success: true, orderId }; + const result = await handlePlaceOrder({ cart }); + return result; } catch (error) { console.error('Failed to place order', error); throw new Error(`Failed to place order, message=${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..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 ( + + + + + + + + + + +
+ {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..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({

- -
+ {analysisOrder && ( +
+
+ +
+ + + +
+ )}
- + {analysisOrder && ( + + )} + {itemsTtoService && ( + + )}
diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index 8511c08..09d50a9 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -4,7 +4,6 @@ import { useRouter } from 'next/navigation'; import { StoreOrderLineItem } from '@medusajs/types'; import { formatDate } from 'date-fns'; -import { Eye } from 'lucide-react'; import { pathsConfig } from '@kit/shared/config'; import { Button } from '@kit/ui/button'; @@ -18,18 +17,22 @@ import { } from '@kit/ui/table'; import { Trans } from '@kit/ui/trans'; -import type { AnalysisOrder } from '~/lib/types/analysis-order'; +import type { Order } from '~/lib/types/order'; import { logAnalysisResultsNavigateAction } from './actions'; +export type OrderItemType = 'analysisOrder' | 'ttoService'; + export default function OrderItemsTable({ items, title, - analysisOrder, + order, + type = 'analysisOrder', }: { items: StoreOrderLineItem[]; title: string; - analysisOrder: AnalysisOrder; + order: Order; + type?: OrderItemType; }) { const router = useRouter(); @@ -37,9 +40,15 @@ export default function OrderItemsTable({ return null; } - const openAnalysisResults = async () => { - await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); - router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + const isAnalysisOrder = type === 'analysisOrder'; + + const openDetailedView = async () => { + if (isAnalysisOrder && order?.medusaOrderId && order?.id) { + await logAnalysisResultsNavigateAction(order.medusaOrderId); + router.push(`${pathsConfig.app.analysisResults}/${order.id}`); + } else { + router.push(`${pathsConfig.app.myOrders}/${order.medusaOrderId}`); + } }; return ( @@ -55,7 +64,7 @@ export default function OrderItemsTable({ - + {isAnalysisOrder && } @@ -76,11 +85,13 @@ export default function OrderItemsTable({ - + - 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..f3998ca --- /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: syncUserId, + startTime, + medusaLineItemId: addedItem.id, + locationId, + comments, + }); + + await updateLineItem({ + lineId: addedItem.id, + quantity: addedItem.quantity, + metadata: { connectedOnlineReservationId: reservation.id }, + }); + } +} diff --git a/app/home/(user)/_lib/server/balance-actions.ts b/app/home/(user)/_lib/server/balance-actions.ts new file mode 100644 index 0000000..425af58 --- /dev/null +++ b/app/home/(user)/_lib/server/balance-actions.ts @@ -0,0 +1,13 @@ +'use server'; + +import { AccountBalanceService, AccountBalanceSummary } from '@kit/accounts/services/account-balance.service'; + +export async function getAccountBalanceSummary(accountId: string): Promise { + try { + const service = new AccountBalanceService(); + return await service.getBalanceSummary(accountId); + } catch (error) { + console.error('Error getting account balance summary:', error); + return null; + } +} diff --git a/app/home/(user)/_lib/server/cart-actions.ts b/app/home/(user)/_lib/server/cart-actions.ts new file mode 100644 index 0000000..b6c95f8 --- /dev/null +++ b/app/home/(user)/_lib/server/cart-actions.ts @@ -0,0 +1,347 @@ +'use server'; + +import { z } from 'zod'; +import jwt from 'jsonwebtoken'; + +import type { StoreCart, StoreOrder } from "@medusajs/types"; + +import { initiateMultiPaymentSession, placeOrder } from "@lib/data/cart"; +import type { AccountBalanceSummary } from "@kit/accounts/services/account-balance.service"; +import { handleNavigateToPayment } from "~/lib/services/medusaCart.service"; +import { loadCurrentUserAccount } from "./load-user-account"; +import { getOrderedAnalysisIds } from "~/lib/services/medusaOrder.service"; +import { createAnalysisOrder, getAnalysisOrder } from "~/lib/services/order.service"; +import { listProductTypes } from "@lib/data"; +import { sendOrderToMedipost } from "~/lib/services/medipost/medipostPrivateMessage.service"; +import { AccountWithParams } from "@/packages/features/accounts/src/types/accounts"; +import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; +import { getSupabaseServerAdminClient } from "@/packages/supabase/src/clients/server-admin-client"; +import { createNotificationsApi } from "@/packages/features/notifications/src/server/api"; +import { FailureReason } from '~/lib/types/connected-online'; +import { getOrderedTtoServices } from '~/lib/services/reservation.service'; +import { bookAppointment } from '~/lib/services/connected-online.service'; + +const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; +const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; + +const env = () => + z + .object({ + emailSender: z + .string({ + error: 'EMAIL_SENDER is required', + }) + .min(1), + siteUrl: z + .string({ + error: 'NEXT_PUBLIC_SITE_URL is required', + }) + .min(1), + isEnabledDispatchOnMontonioCallback: z.boolean({ + error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required', + }), + medusaBackendPublicUrl: z.string({ + error: 'MEDUSA_BACKEND_PUBLIC_URL is required', + }).min(1), + companyBenefitsPaymentSecretKey: z.string({ + error: 'COMPANY_BENEFITS_PAYMENT_SECRET_KEY is required', + }).min(1), + }) + .parse({ + emailSender: process.env.EMAIL_SENDER, + siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, + isEnabledDispatchOnMontonioCallback: + process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', + medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!, + companyBenefitsPaymentSecretKey: process.env.COMPANY_BENEFITS_PAYMENT_SECRET_KEY!, + }); + +export const initiatePayment = async ({ + accountId, + balanceSummary, + cart, + language, +}: { + accountId: string; + balanceSummary: AccountBalanceSummary; + cart: StoreCart; + language: string; +}) => { + try { + const { + montonioPaymentSessionId, + companyBenefitsPaymentSessionId, + totalByMontonio, + totalByBenefits, + isFullyPaidByBenefits, + } = await initiateMultiPaymentSession(cart, balanceSummary.totalBalance); + + if (!isFullyPaidByBenefits) { + if (!montonioPaymentSessionId) { + throw new Error('Montonio payment session ID is missing'); + } + const props = await handleNavigateToPayment({ + language, + paymentSessionId: montonioPaymentSessionId, + amount: totalByMontonio, + currencyCode: cart.currency_code, + cartId: cart.id, + }); + return { ...props, isFullyPaidByBenefits }; + } else { + // place order if all paid already + const { orderId } = await handlePlaceOrder({ cart }); + + const companyBenefitsOrderToken = jwt.sign({ + accountId, + companyBenefitsPaymentSessionId, + orderId, + totalByBenefits, + }, env().companyBenefitsPaymentSecretKey, { + algorithm: 'HS256', + }); + const webhookResponse = await fetch(`${env().medusaBackendPublicUrl}/hooks/payment/company-benefits_company-benefits`, { + method: 'POST', + body: JSON.stringify({ + orderToken: companyBenefitsOrderToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!webhookResponse.ok) { + throw new Error('Failed to send company benefits webhook'); + } + return { isFullyPaidByBenefits, orderId, unavailableLineItemIds: [] }; + } + } catch (error) { + console.error('Error initiating payment', error); + } + + return { url: null, isFullyPaidByBenefits: false, orderId: null, unavailableLineItemIds: [] }; +} + +export async function handlePlaceOrder({ + cart, +}: { + cart: StoreCart; +}) { + const { account } = await loadCurrentUserAccount(); + if (!account) { + throw new Error('Account not found in context'); + } + + try { + const medusaOrder = await placeOrder(cart.id, { + revalidateCacheTags: false, + }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ + medusaOrder, + }); + + const orderContainsSynlabItems = !!orderedAnalysisElements?.length; + + try { + const existingAnalysisOrder = await getAnalysisOrder({ + medusaOrderId: medusaOrder.id, + }); + console.info( + `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, + ); + return { success: true, orderId: existingAnalysisOrder.id }; + } catch { + // ignored + } + + let orderId: number | undefined = undefined; + 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({ + account, + email, + analysisPackageOrder, + }); + } else { + console.info(`Order has no analysis package, skipping email.`); + } + + if (analysisItemsOrder) { + // @TODO send email for separate analyses + console.warn( + `Order has analysis items, but no email template exists yet`, + ); + } else { + console.info(`Order has no analysis items, skipping email.`); + } + } else { + console.error('Missing email to send order result email', orderResult); + } + + if (env().isEnabledDispatchOnMontonioCallback) { + 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); + throw new Error(`Failed to place order, message=${error}`); + } +} + +async function sendAnalysisPackageOrderEmail({ + account, + email, + analysisPackageOrder, +}: { + account: AccountWithParams; + email: string; + analysisPackageOrder: { + partnerLocationName: string; + analysisPackageName: string; + }; +}) { + const { language } = await createI18nServerInstance(); + const { analysisPackageName, partnerLocationName } = analysisPackageOrder; + try { + await sendEmail({ + account: { id: account.id, name: account.name }, + email, + analysisPackageName, + partnerLocationName, + language, + }); + console.info(`Successfully sent analysis package order email to ${email}`); + } catch (error) { + console.error( + `Failed to send analysis package order email to ${email}`, + error, + ); + } +} + +async function getOrderResultParameters(medusaOrder: StoreOrder) { + const { productTypes } = await listProductTypes(); + const analysisPackagesType = productTypes.find( + ({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE, + ); + const analysisType = productTypes.find( + ({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE, + ); + + const analysisPackageOrderItem = medusaOrder.items?.find( + ({ product_type_id }) => product_type_id === analysisPackagesType?.id, + ); + const analysisItems = medusaOrder.items?.filter( + ({ product_type_id }) => product_type_id === analysisType?.id, + ); + + return { + medusaOrderId: medusaOrder.id, + email: medusaOrder.email, + analysisPackageOrder: analysisPackageOrderItem + ? { + partnerLocationName: + (analysisPackageOrderItem?.metadata + ?.partner_location_name as string) ?? '', + analysisPackageName: analysisPackageOrderItem?.title ?? '', + } + : null, + analysisItemsOrder: + Array.isArray(analysisItems) && analysisItems.length > 0 + ? analysisItems.map(({ product }) => ({ + analysisName: product?.title ?? '', + analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', + })) + : null, + }; +} + +const sendEmail = async ({ + account, + email, + analysisPackageName, + partnerLocationName, + language, +}: { + account: Pick; + email: string; + analysisPackageName: string; + partnerLocationName: string; + language: string; +}) => { + const client = getSupabaseServerAdminClient(); + try { + const { renderSynlabAnalysisPackageEmail } = await import( + '@kit/email-templates' + ); + const { getMailer } = await import('@kit/mailers'); + + const mailer = await getMailer(); + + const { html, subject } = await renderSynlabAnalysisPackageEmail({ + analysisPackageName, + personName: account.name, + partnerLocationName, + language, + }); + + await mailer + .sendEmail({ + from: env().emailSender, + to: email, + subject, + html, + }) + .catch((error) => { + throw new Error(`Failed to send email, message=${error}`); + }); + await createNotificationsApi(client).createNotification({ + account_id: account.id, + body: html, + }); + } catch (error) { + throw new Error(`Failed to send email, message=${error}`); + } +}; diff --git a/app/home/(user)/_lib/server/is-valid-open-ai-env.ts b/app/home/(user)/_lib/server/is-valid-open-ai-env.ts new file mode 100644 index 0000000..183a8f9 --- /dev/null +++ b/app/home/(user)/_lib/server/is-valid-open-ai-env.ts @@ -0,0 +1,12 @@ +import OpenAI from 'openai'; + +export const isValidOpenAiEnv = async () => { + try { + const client = new OpenAI(); + await client.models.list(); + return true; + } catch (e) { + console.log('No openAI env'); + return false; + } +}; 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-benefit-statistics.tsx b/app/home/[account]/_components/team-account-benefit-statistics.tsx index dd4d983..81f232d 100644 --- a/app/home/[account]/_components/team-account-benefit-statistics.tsx +++ b/app/home/[account]/_components/team-account-benefit-statistics.tsx @@ -1,23 +1,14 @@ import React from 'react'; -import { redirect } from 'next/navigation'; - import { formatCurrency } from '@/packages/shared/src/utils'; -import { Database } from '@/packages/supabase/src/database.types'; -import { PiggyBankIcon, Settings } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { createPath, pathsConfig } from '@kit/shared/config'; import { Card, CardTitle } from '@kit/ui/card'; import { cn } from '@kit/ui/lib/utils'; -import { Button } from '@kit/ui/shadcn/button'; import { Trans } from '@kit/ui/trans'; -interface TeamAccountBenefitStatisticsProps { - employeeCount: number; - accountSlug: string; - companyParams: Database['medreport']['Tables']['company_params']['Row']; -} +import { TeamAccountBenefitExpensesOverview } from '../_lib/server/load-team-account-benefit-expenses-overview'; +import { AccountBenefitStatistics } from '../_lib/server/load-team-account-benefit-statistics'; const StatisticsCard = ({ children }: { children: React.ReactNode }) => { return {children}; @@ -46,126 +37,90 @@ const StatisticsValue = ({ children }: { children: React.ReactNode }) => { }; const TeamAccountBenefitStatistics = ({ - employeeCount, - accountSlug, - companyParams, -}: TeamAccountBenefitStatisticsProps) => { + accountBenefitStatistics, + expensesOverview, +}: { + accountBenefitStatistics: AccountBenefitStatistics; + expensesOverview: TeamAccountBenefitExpensesOverview; +}) => { const { i18n: { language }, } = useTranslation(); return (
- -
- -
- -
-

- -

-

- -

- - - -
-
-
- + - 1800 € + + {accountBenefitStatistics.companyAccountsCount} + + + + + + + + {formatCurrency({ + value: accountBenefitStatistics.orders.totalSum, + locale: language, + currencyCode: 'EUR', + })} + + + + + + + + + {formatCurrency({ + value: expensesOverview.currentMonthUsageTotal, + locale: language, + currencyCode: 'EUR', + })} + + + - 200 € + + {formatCurrency({ + value: accountBenefitStatistics.orders.analysesSum, + locale: language, + currencyCode: 'EUR', + })} + - - - - - - - 200 € - - - - - - - - - 200 € - - - - - - - - - 200 € - - - + - 200 € + + {formatCurrency({ + value: accountBenefitStatistics.orders.analysisPackagesSum, + locale: language, + currencyCode: 'EUR', + })} + diff --git a/app/home/[account]/_components/team-account-health-details.tsx b/app/home/[account]/_components/team-account-health-details.tsx index 666a029..547a8a8 100644 --- a/app/home/[account]/_components/team-account-health-details.tsx +++ b/app/home/[account]/_components/team-account-health-details.tsx @@ -5,6 +5,7 @@ import { Database } from '@/packages/supabase/src/database.types'; import { Card } from '@kit/ui/card'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; +import type { BmiThresholds } from '@kit/accounts/types/accounts'; import { getAccountHealthDetailsFields } from '../_lib/server/load-team-account-health-details'; import { TeamAccountStatisticsProps } from './team-account-statistics'; @@ -15,10 +16,7 @@ const TeamAccountHealthDetails = ({ members, }: { memberParams: TeamAccountStatisticsProps['memberParams']; - bmiThresholds: Omit< - Database['medreport']['Tables']['bmi_thresholds']['Row'], - 'id' - >[]; + bmiThresholds: Omit[]; members: Database['medreport']['Functions']['get_account_members']['Returns']; }) => { const accountHealthDetailsFields = getAccountHealthDetailsFields( 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]/_components/team-account-statistics.tsx b/app/home/[account]/_components/team-account-statistics.tsx index fe8af56..17a7466 100644 --- a/app/home/[account]/_components/team-account-statistics.tsx +++ b/app/home/[account]/_components/team-account-statistics.tsx @@ -14,28 +14,21 @@ import { createPath, pathsConfig } from '@kit/shared/config'; import { Card } from '@kit/ui/card'; import { Trans } from '@kit/ui/makerkit/trans'; import { Button } from '@kit/ui/shadcn/button'; -import { Calendar, DateRange } from '@kit/ui/shadcn/calendar'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@kit/ui/shadcn/popover'; +import { DateRange } from '@kit/ui/shadcn/calendar'; +import { AccountBenefitStatistics } from '../_lib/server/load-team-account-benefit-statistics'; import TeamAccountBenefitStatistics from './team-account-benefit-statistics'; import TeamAccountHealthDetails from './team-account-health-details'; +import type { Account, AccountParams, BmiThresholds } from '@/packages/features/accounts/src/types/accounts'; +import { TeamAccountBenefitExpensesOverview } from '../_lib/server/load-team-account-benefit-expenses-overview'; export interface TeamAccountStatisticsProps { - teamAccount: Database['medreport']['Tables']['accounts']['Row']; - memberParams: Pick< - Database['medreport']['Tables']['account_params']['Row'], - 'weight' | 'height' - >[]; - bmiThresholds: Omit< - Database['medreport']['Tables']['bmi_thresholds']['Row'], - 'id' - >[]; + teamAccount: Account; + memberParams: Pick[]; + bmiThresholds: Omit[]; members: Database['medreport']['Functions']['get_account_members']['Returns']; - companyParams: Database['medreport']['Tables']['company_params']['Row']; + accountBenefitStatistics: AccountBenefitStatistics; + expensesOverview: TeamAccountBenefitExpensesOverview; } export default function TeamAccountStatistics({ @@ -43,11 +36,13 @@ export default function TeamAccountStatistics({ memberParams, bmiThresholds, members, - companyParams, + accountBenefitStatistics, + expensesOverview, }: TeamAccountStatisticsProps) { + const currentDate = new Date(); const [date, setDate] = useState({ - from: new Date(), - to: new Date(), + from: new Date(currentDate.getFullYear(), currentDate.getMonth(), 1), + to: new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0), }); const { i18n: { language }, @@ -58,7 +53,7 @@ export default function TeamAccountStatistics({ return ( <> -
+

- - - - - - - - +
- +
@@ -148,7 +127,7 @@ export default function TeamAccountStatistics({ redirect( createPath( pathsConfig.app.accountBilling, - teamAccount.slug || '', + teamAccount.slug!, ), ) } diff --git a/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts b/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts new file mode 100644 index 0000000..0f260ea --- /dev/null +++ b/app/home/[account]/_lib/server/load-team-account-benefit-expenses-overview.ts @@ -0,0 +1,75 @@ +import { getSupabaseServerClient } from "@/packages/supabase/src/clients/server-client"; +import { loadCompanyPersonalAccountsBalanceEntries } from "./load-team-account-benefit-statistics"; + +export interface TeamAccountBenefitExpensesOverview { + benefitAmount: number | null; + benefitOccurrence: 'yearly' | 'monthly' | 'quarterly' | null; + currentMonthUsageTotal: number; + managementFee: number; + managementFeeTotal: number; + total: number; +} + +const MANAGEMENT_FEE = 5.50; + +const MONTHS = 12; +const QUARTERS = 4; + +export async function loadTeamAccountBenefitExpensesOverview({ + companyId, + employeeCount, +}: { + companyId: string; + employeeCount: number; +}): Promise { + const supabase = getSupabaseServerClient(); + const { data, error } = await supabase + .schema('medreport') + .from('benefit_distribution_schedule') + .select('*') + .eq('company_id', companyId) + .eq('is_active', true) + .single(); + + let benefitAmount: TeamAccountBenefitExpensesOverview['benefitAmount'] = null; + let benefitOccurrence: TeamAccountBenefitExpensesOverview['benefitOccurrence'] = null; + if (error) { + console.warn('Failed to load team account benefit expenses overview'); + } else { + benefitAmount = data.benefit_amount as TeamAccountBenefitExpensesOverview['benefitAmount']; + benefitOccurrence = data.benefit_occurrence as TeamAccountBenefitExpensesOverview['benefitOccurrence']; + } + + const { purchaseEntriesTotal } = await loadCompanyPersonalAccountsBalanceEntries({ accountId: companyId }); + + return { + benefitAmount, + benefitOccurrence, + currentMonthUsageTotal: purchaseEntriesTotal, + managementFee: MANAGEMENT_FEE, + managementFeeTotal: MANAGEMENT_FEE * employeeCount, + total: (() => { + if (typeof benefitAmount !== 'number') { + return 0; + } + + const currentDate = new Date(); + const createdAt = new Date(data.created_at); + const isCreatedThisYear = createdAt.getFullYear() === currentDate.getFullYear(); + if (benefitOccurrence === 'yearly') { + return benefitAmount * employeeCount; + } else if (benefitOccurrence === 'monthly') { + const monthsLeft = isCreatedThisYear + ? MONTHS - createdAt.getMonth() + : MONTHS; + return benefitAmount * employeeCount * monthsLeft; + } else if (benefitOccurrence === 'quarterly') { + const quartersLeft = isCreatedThisYear + ? QUARTERS - Math.ceil(createdAt.getMonth() / 3) + : QUARTERS; + return benefitAmount * employeeCount * quartersLeft; + } + return 0; + })(), + } +} diff --git a/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts b/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts new file mode 100644 index 0000000..f61f350 --- /dev/null +++ b/app/home/[account]/_lib/server/load-team-account-benefit-statistics.ts @@ -0,0 +1,95 @@ +'use server'; + +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +export interface AccountBenefitStatistics { + benefitDistributionSchedule: { + amount: number; + }; + companyAccountsCount: number; + periodTotal: number; + orders: { + totalSum: number; + + analysesCount: number; + analysesSum: number; + + analysisPackagesCount: number; + analysisPackagesSum: number; + } +} + +export const loadCompanyPersonalAccountsBalanceEntries = async ({ + accountId, +}: { + accountId: string; +}) => { + const supabase = getSupabaseServerAdminClient(); + + const { count, data: accountMemberships } = await supabase + .schema('medreport') + .from('accounts_memberships') + .select('user_id', { count: 'exact' }) + .eq('account_id', accountId) + .throwOnError(); + + const { data: accountBalanceEntries } = await supabase + .schema('medreport') + .from('account_balance_entries') + .select('*') + .eq('is_active', true) + .in('account_id', accountMemberships.map(({ user_id }) => user_id)) + .throwOnError(); + + const purchaseEntries = accountBalanceEntries.filter(({ entry_type }) => entry_type === 'purchase'); + const analysesEntries = purchaseEntries.filter(({ is_analysis_order }) => is_analysis_order); + const analysisPackagesEntries = purchaseEntries.filter(({ is_analysis_package_order }) => is_analysis_package_order); + + return { + accountBalanceEntries, + analysesEntries, + analysisPackagesEntries, + companyAccountsCount: count || 0, + purchaseEntries, + purchaseEntriesTotal: purchaseEntries.reduce((acc, { amount }) => acc + Math.abs(amount || 0), 0), + }; +} + +export const loadAccountBenefitStatistics = async ( + accountId: string, +): Promise => { + const supabase = getSupabaseServerAdminClient(); + + const { + analysesEntries, + analysisPackagesEntries, + companyAccountsCount, + purchaseEntriesTotal, + } = await loadCompanyPersonalAccountsBalanceEntries({ accountId }); + + const { data: benefitDistributionSchedule } = await supabase + .schema('medreport') + .from('benefit_distribution_schedule') + .select('*') + .eq('company_id', accountId) + .eq('is_active', true) + .single(); + + const scheduleAmount = benefitDistributionSchedule?.benefit_amount || 0; + return { + companyAccountsCount, + benefitDistributionSchedule: { + amount: benefitDistributionSchedule?.benefit_amount || 0, + }, + periodTotal: scheduleAmount * companyAccountsCount, + orders: { + totalSum: purchaseEntriesTotal, + + analysesCount: analysesEntries.length, + analysesSum: analysesEntries.reduce((acc, { amount }) => acc + Math.abs(amount || 0), 0), + + analysisPackagesCount: analysisPackagesEntries.length, + analysisPackagesSum: analysisPackagesEntries.reduce((acc, { amount }) => acc + Math.abs(amount || 0), 0), + }, + }; +}; diff --git a/app/home/[account]/_lib/server/load-team-account-health-details.ts b/app/home/[account]/_lib/server/load-team-account-health-details.ts index 1705770..9568dda 100644 --- a/app/home/[account]/_lib/server/load-team-account-health-details.ts +++ b/app/home/[account]/_lib/server/load-team-account-health-details.ts @@ -11,6 +11,7 @@ import { } from '~/lib/utils'; import { TeamAccountStatisticsProps } from '../../_components/team-account-statistics'; +import type { BmiThresholds } from '@kit/accounts/types/accounts'; interface AccountHealthDetailsField { title: string; @@ -25,10 +26,7 @@ interface AccountHealthDetailsField { export const getAccountHealthDetailsFields = ( memberParams: TeamAccountStatisticsProps['memberParams'], - bmiThresholds: Omit< - Database['medreport']['Tables']['bmi_thresholds']['Row'], - 'id' - >[], + bmiThresholds: Omit[], members: Database['medreport']['Functions']['get_account_members']['Returns'], ): AccountHealthDetailsField[] => { const averageWeight = @@ -82,7 +80,7 @@ export const getAccountHealthDetailsFields = ( }, { title: 'teams:healthDetails.bmi', - value: averageBMI, + value: averageBMI!, Icon: TrendingUp, iconBg: getBmiBackgroundColor(bmiStatus), }, 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 = () => {