1 Commits

Author SHA1 Message Date
70d5b78ca8 wip 2025-09-28 07:26:46 +03:00
250 changed files with 4590 additions and 9900 deletions

2
.env
View File

@@ -33,7 +33,7 @@ NEXT_PUBLIC_LOCALES_PATH=apps/web/public/locales
NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true

View File

@@ -10,10 +10,6 @@ NEXT_PUBLIC_AUTH_PASSWORD=true
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION ## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
# MEDUSA MONTONIO URLS FOR LOCALHOST
# Montonio doesn't allow localhost as notification/callback URL
DEV_MONTONIO_CALLBACK_URL=http://webhook.site:3000
# EMAILS # EMAILS
# CONTACT FORM # CONTACT FORM
@@ -35,14 +31,14 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
MEDIPOST_USER=trvurgtst MEDIPOST_USER=trvurgtst
MEDIPOST_PASSWORD=SRB48HZMV MEDIPOST_PASSWORD=SRB48HZMV
MEDIPOST_RECIPIENT=syndev
MEDIPOST_RECIPIENT=trvurgtst MEDIPOST_RECIPIENT=trvurgtst
MEDIPOST_MESSAGE_SENDER=trvurgtst MEDIPOST_MESSAGE_SENDER=trvurgtst
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true
MEDIPOST_ENABLE_DELETE_RESPONSE_PRIVATE_MESSAGE_ON_READ=false
#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet #MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
#MEDIPOST_USER=medreport #MEDIPOST_USER=medreport
#MEDIPOST_PASSWORD= #MEDIPOST_PASSWORD=85MXFFDB7
#MEDIPOST_RECIPIENT=HTI #MEDIPOST_RECIPIENT=HTI
#MEDIPOST_MESSAGE_SENDER=medreport #MEDIPOST_MESSAGE_SENDER=medreport
#MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false #MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false
@@ -51,11 +47,48 @@ MEDIPOST_ENABLE_DELETE_RESPONSE_PRIVATE_MESSAGE_ON_READ=false
COMPANY_BENEFITS_PAYMENT_SECRET_KEY=NzcwMzE2NmEtOThiMS0xMWYwLWI4NjYtMDMwZDQzMjFhMjExCg== COMPANY_BENEFITS_PAYMENT_SECRET_KEY=NzcwMzE2NmEtOThiMS0xMWYwLWI4NjYtMDMwZDQzMjFhMjExCg==
MEDUSA_BACKEND_URL=http://localhost:9000 MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000 MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
MEDUSA_SECRET_API_KEY=sk_b332d525212ab4078ef73fb2b8232c3beebccc4a460e2c7abf6e187a458d60cf
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb0768ff4520516285bdef84
#MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
#MEDUSA_SECRET_API_KEY=sk_5ac1c1c12c144cd744b6c881050d459e339ddf6a3d14eda271a0cc4f9d3812cb
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e740b9ca22b31c4b44862044f001dbcf8f46d47d40f430733d0c75bef14d2d6a
#MEDUSA_BACKEND_URL=https://backoffice.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice.medreport.ee
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_068d930c33fea53608a410d84a51935f6ce2ccec5bef8e0ecf75eaee602ac486
#MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
#MEDUSA_BACKEND_URL=http://5.181.51.38:9000
#MEDUSA_BACKEND_PUBLIC_URL=http://5.181.51.38:9000
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
# MONTONIO # MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com MONTONIO_API_URL=https://sandbox-stargate.montonio.com
#NEXT_PUBLIC_MONTONIO_ACCESS_KEY=13e3686a-e7ad-41f6-998b-3f7d7de17654
#MONTONIO_SECRET_KEY=wTd4BZ01h80KZLMPL4mjt0RCFxKaYRSu9mMB1PQZCxnw
#MONTONIO_API_URL=https://stargate.montonio.com
# JOBS # JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197 JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1Njk4NDYyOCwiZXhwIjoyMDcyNTYwNjI4fQ.1UZR7AqSD9bOy1gtZRGhOCNoESsw2W-DoFDDsNNMwoE
#NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
#NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
#SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
### TEST.MEDREPORT.ee ###
DB_PASSWORD=imCTUreSnazWKT3u#

View File

@@ -10,7 +10,6 @@ MEDIPOST_PASSWORD=your-medipost-password
MEDIPOST_RECIPIENT=your-medipost-recipient MEDIPOST_RECIPIENT=your-medipost-recipient
CONNECTED_ONLINE_URL=your-connected-online-url CONNECTED_ONLINE_URL=your-connected-online-url
CONNECTED_ONLINE_CONFIRMED_URL=your-connected-confirmed-url
EMAIL_SENDER= EMAIL_SENDER=
EMAIL_USER= # refer to your email provider's documentation EMAIL_USER= # refer to your email provider's documentation
@@ -20,7 +19,6 @@ EMAIL_PORT= # or 465 for SSL
EMAIL_TLS= # or false for SSL (see provider documentation) EMAIL_TLS= # or false for SSL (see provider documentation)
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
MEDUSA_SECRET_API_KEY=
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo

View File

@@ -6,10 +6,10 @@
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE. ## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
# SUPABASE # SUPABASE
# NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0 NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# # MONTONIO # # MONTONIO
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

View File

@@ -1,5 +1,3 @@
database.types.ts database.types.ts
playwright-report playwright-report
*.hbs *.hbs
.history
node_modules

View File

@@ -98,13 +98,13 @@ To access admin pages follow these steps:
- Register new user - Register new user
- Go to Profile and add Multi-Factor Authentication - Go to Profile and add Multi-Factor Authentication
- Authenticate with mfa (at current time profile page prompts it again) - Authenticate with mfa (at current time profile page prompts it again)
- update your `account.application_role` to `super_admin`. - update your role. look at `supabase/sql/super-admin.sql`
- Sign out and Sign in - Sign out and Sign in
## Company User ## Company User
- With admin account go to `http://localhost:3000/admin/accounts` - With admin account go to `http://localhost:3000/admin/accounts`
- For Create Company Account to work you need to have rows in `medreport.roles` table. - For Create Company Account to work you need to have rows in `medreport.roles` table. For that you can sql in `supabase/sql/super-admin.sql`
## Start email server ## Start email server

View File

@@ -19,8 +19,6 @@ import { Label } from '@kit/ui/label';
import { Spinner } from '@kit/ui/spinner'; import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { sendCompanyOfferEmail } from '../_lib/server/company-offer-actions';
const CompanyOfferForm = () => { const CompanyOfferForm = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -36,16 +34,6 @@ const CompanyOfferForm = () => {
const onSubmit = async (data: CompanySubmitData) => { const onSubmit = async (data: CompanySubmitData) => {
setIsLoading(true); setIsLoading(true);
try {
await sendCompanyOfferEmail(data, language);
router.push('/company-offer/success');
} catch (err) {
setIsLoading(false);
if (err instanceof Error) {
console.warn('Could not send company offer email: ' + err.message);
}
console.warn('Could not send company offer email: ', err);
}
const formData = new FormData(); const formData = new FormData();
Object.entries(data).forEach(([key, value]) => { Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) formData.append(key, value); if (value !== undefined) formData.append(key, value);

View File

@@ -1,25 +0,0 @@
'use server';
import { renderCompanyOfferEmail } from '@/packages/email-templates/src';
import { sendEmailFromTemplate } from '~/lib/services/mailer.service';
import { CompanySubmitData } from '~/lib/types/company';
export const sendCompanyOfferEmail = async (
data: CompanySubmitData,
language: string,
) => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) formData.append(key, value);
});
sendEmailFromTemplate(
renderCompanyOfferEmail,
{
companyData: data,
language,
},
process.env.CONTACT_EMAIL!,
);
};

View File

@@ -1,27 +0,0 @@
import { enhanceRouteHandler } from '@/packages/next/src/routes';
import { createAuthCallbackService } from '@/packages/supabase/src/auth-callback.service';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
export const POST = enhanceRouteHandler(
async () => {
try {
const supabaseClient = getSupabaseServerClient();
const {
data: { user },
} = await supabaseClient.auth.getUser();
const service = createAuthCallbackService(supabaseClient);
if (user && service.isKeycloakUser(user)) {
await service.setupMedusaUserForKeycloak(user);
}
return new Response(null, { status: 200 });
} catch (err) {
console.error('Error on verifying:', { err });
return new Response(null, { status: 500 });
}
},
{
auth: false,
},
);

View File

@@ -7,19 +7,11 @@ import { sendEmailFromTemplate } from '~/lib/services/mailer.service';
export default async function sendOpenJobsEmails() { export default async function sendOpenJobsEmails() {
const analysisResponseIds = await getOpenJobAnalysisResponseIds(); const analysisResponseIds = await getOpenJobAnalysisResponseIds();
if (analysisResponseIds.length === 0) {
return;
}
const doctorAccounts = await getDoctorAccounts(); const doctorAccounts = await getDoctorAccounts();
const doctorEmails = doctorAccounts const doctorEmails: string[] = doctorAccounts
.map(({ email }) => email) .map(({ email }) => email)
.filter((email): email is string => !!email); .filter((email): email is string => !!email);
if (doctorEmails.length === 0) {
return [];
}
await sendEmailFromTemplate( await sendEmailFromTemplate(
renderNewJobsAvailableEmail, renderNewJobsAvailableEmail,
{ {
@@ -28,6 +20,4 @@ export default async function sendOpenJobsEmails() {
}, },
doctorEmails, doctorEmails,
); );
return doctorAccounts.filter((email) => !!email).map(({ id }) => id);
} }

View File

@@ -41,7 +41,7 @@ export default async function syncAnalysisGroups() {
try { try {
console.info('Getting latest public message id'); console.info('Getting latest public message id');
// const lastCheckedDate = await getLastCheckedDate(); never used? const lastCheckedDate = await getLastCheckedDate();
const latestMessage = await getLatestPublicMessageListItem(); const latestMessage = await getLatestPublicMessageListItem();
if (!latestMessage) { if (!latestMessage) {

View File

@@ -1,4 +1,4 @@
import MedipostPrivateMessageSync from '~/lib/services/medipost/medipostPrivateMessageSync.service'; import { readPrivateMessageResponse } from '~/lib/services/medipost/medipostPrivateMessage.service';
type ProcessedMessage = { type ProcessedMessage = {
messageId: string; messageId: string;
@@ -16,22 +16,22 @@ type GroupedResults = {
export default async function syncAnalysisResults() { export default async function syncAnalysisResults() {
console.info('Syncing analysis results'); console.info('Syncing analysis results');
const sync = new MedipostPrivateMessageSync();
const processedMessages: ProcessedMessage[] = []; const processedMessages: ProcessedMessage[] = [];
const excludedMessageIds: string[] = []; const excludedMessageIds: string[] = [];
while (true) { while (true) {
const result = await sync.handleNextPrivateMessage({ excludedMessageIds }); const result = await readPrivateMessageResponse({ excludedMessageIds });
if (result.messageId) {
processedMessages.push(result as ProcessedMessage);
}
const { messageId } = result; if (!result.messageId) {
if (!messageId) {
console.info('No more messages to process'); console.info('No more messages to process');
break; break;
} }
processedMessages.push(result as ProcessedMessage); if (!excludedMessageIds.includes(result.messageId)) {
if (!excludedMessageIds.includes(messageId)) { excludedMessageIds.push(result.messageId);
excludedMessageIds.push(messageId);
} else { } else {
break; break;
} }

View File

@@ -81,19 +81,21 @@ 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 // Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment
const isDemoClinic = (clinicId: number) => const isDemoClinic = (clinicId: number) =>
isProd ? clinicId !== 2 : clinicId === 2; isProd ? clinicId !== 2 : clinicId === 2;
const clinics = responseData.Data.T_Lic.filter(({ ID }) => clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID));
isDemoClinic(ID), services = responseData.Data.T_Service.filter(({ ClinicID }) =>
);
const services = responseData.Data.T_Service.filter(({ ClinicID }) =>
isDemoClinic(ClinicID), isDemoClinic(ClinicID),
); );
const serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) =>
isDemoClinic(ClinicID), isDemoClinic(ClinicID),
); );
const jobTitleTranslations = createTranslationMap( jobTitleTranslations = createTranslationMap(
responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) =>
isDemoClinic(ClinicID), isDemoClinic(ClinicID),
), ),

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getLogger } from '@/packages/shared/src/logger';
import { retrieveOrder } from '@lib/data/orders'; import { retrieveOrder } from '@lib/data/orders';
import { getMedipostDispatchTries } from '~/lib/services/audit.service'; import { getMedipostDispatchTries } from '~/lib/services/audit.service';
@@ -11,17 +10,13 @@ import loadEnv from '../handler/load-env';
import validateApiKey from '../handler/validate-api-key'; import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
const logger = await getLogger();
const ctx = {
api: '/job/medipost-retry-dispatch',
};
loadEnv(); loadEnv();
const { medusaOrderId } = await request.json(); const { medusaOrderId } = await request.json();
try { try {
validateApiKey(request); validateApiKey(request);
} catch { } catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
@@ -41,15 +36,15 @@ export const POST = async (request: NextRequest) => {
medusaOrder, medusaOrder,
}); });
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
logger.info(ctx, 'Successfully sent order to medipost'); console.info('Successfully sent order to medipost');
return NextResponse.json( return NextResponse.json(
{ {
message: 'Successfully sent order to medipost', message: 'Successfully sent order to medipost',
}, },
{ status: 200 }, { status: 200 },
); );
} catch (error) { } catch (e) {
logger.error({ ...ctx, error }, 'Error sending order to medipost'); console.error('Error sending order to medipost', e);
return NextResponse.json( return NextResponse.json(
{ {
message: 'Failed to send order to medipost', message: 'Failed to send order to medipost',

View File

@@ -14,20 +14,18 @@ export const POST = async (request: NextRequest) => {
try { try {
validateApiKey(request); validateApiKey(request);
} catch { } catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
try { try {
const doctors = await sendOpenJobsEmails(); await sendOpenJobsEmails();
const doctorIds = doctors?.join(', ') ?? '-';
console.info( console.info(
`Successfully sent out open job notification emails to doctorIds: ${doctorIds}`, 'Successfully sent out open job notification emails to doctors.',
); );
await createNotificationLog({ await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS, action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'SUCCESS', status: 'SUCCESS',
comment: `doctors that received email: ${doctorIds}`,
}); });
return NextResponse.json( return NextResponse.json(
{ {
@@ -36,7 +34,7 @@ export const POST = async (request: NextRequest) => {
}, },
{ status: 200 }, { status: 200 },
); );
} catch (e) { } catch (e: any) {
console.error( console.error(
'Error sending out open job notification emails to doctors.', 'Error sending out open job notification emails to doctors.',
e, e,
@@ -44,7 +42,7 @@ export const POST = async (request: NextRequest) => {
await createNotificationLog({ await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS, action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'FAIL', status: 'FAIL',
comment: e instanceof Error ? e.message : 'Unknown error', comment: e?.message,
}); });
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -9,7 +9,7 @@ export const POST = async (request: NextRequest) => {
try { try {
validateApiKey(request); validateApiKey(request);
} catch { } catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }

View File

@@ -9,7 +9,7 @@ export const POST = async (request: NextRequest) => {
try { try {
validateApiKey(request); validateApiKey(request);
} catch { } catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }

View File

@@ -9,7 +9,7 @@ export const POST = async (request: NextRequest) => {
try { try {
validateApiKey(request); validateApiKey(request);
} catch { } catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }

View File

@@ -9,7 +9,7 @@ export const POST = async (request: NextRequest) => {
try { try {
validateApiKey(request); validateApiKey(request);
} catch { } catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }

View File

@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
try { try {
validateApiKey(request); validateApiKey(request);
} catch { } catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }

View File

@@ -54,7 +54,6 @@ export async function POST(request: Request) {
action: 'send_fake_analysis_results_to_medipost', action: 'send_fake_analysis_results_to_medipost',
xml: messageXml, xml: messageXml,
medusaOrderId, medusaOrderId,
medipostPrivateMessageId: `fake-response-${Date.now()}`,
}); });
await sendPrivateMessageTestResponse({ messageXml }); await sendPrivateMessageTestResponse({ messageXml });
} catch (error) { } catch (error) {

View File

@@ -47,11 +47,6 @@ export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient()); const service = createAuthCallbackService(getSupabaseServerClient());
const oauthResult = await service.exchangeCodeForSession(authCode); const oauthResult = await service.exchangeCodeForSession(authCode);
if (oauthResult.requiresMultiFactorAuthentication) {
redirect(pathsConfig.auth.verifyMfa);
}
if (!('isSuccess' in oauthResult)) { if (!('isSuccess' in oauthResult)) {
return redirectOnError(oauthResult.searchParams); return redirectOnError(oauthResult.searchParams);
} }

View File

@@ -25,7 +25,7 @@ const MembershipConfirmationNotification: React.FC<{
descriptionKey="account:membershipConfirmation:successDescription" descriptionKey="account:membershipConfirmation:successDescription"
buttonProps={{ buttonProps={{
buttonTitleKey: 'account:membershipConfirmation:successButton', buttonTitleKey: 'account:membershipConfirmation:successButton',
href: pathsConfig.app.selectPackage, href: pathsConfig.app.home,
}} }}
/> />
); );

View File

@@ -1,3 +1,5 @@
import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) { async function SiteLayout(props: React.PropsWithChildren) {
return ( return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}> <div className={'flex min-h-[100vh] flex-col items-center justify-center'}>

View File

@@ -181,6 +181,7 @@ export function UpdateAccountForm({
)} )}
/> />
{!isEmailUser && (
<> <>
<FormField <FormField
name="city" name="city"
@@ -213,7 +214,9 @@ export function UpdateAccountForm({
value={field.value ?? ''} value={field.value ?? ''}
onChange={(e) => onChange={(e) =>
field.onChange( field.onChange(
e.target.value === '' ? null : Number(e.target.value), e.target.value === ''
? null
: Number(e.target.value),
) )
} }
/> />
@@ -238,7 +241,9 @@ export function UpdateAccountForm({
value={field.value ?? ''} value={field.value ?? ''}
onChange={(e) => onChange={(e) =>
field.onChange( field.onChange(
e.target.value === '' ? null : Number(e.target.value), e.target.value === ''
? null
: Number(e.target.value),
) )
} }
/> />
@@ -249,6 +254,7 @@ export function UpdateAccountForm({
/> />
</div> </div>
</> </>
)}
<FormField <FormField
name="userConsent" name="userConsent"

View File

@@ -4,6 +4,7 @@ import { updateCustomer } from '@lib/data/customer';
import { AccountSubmitData, createAuthApi } from '@kit/auth/api'; import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { pathsConfig } from '@kit/shared/config';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { UpdateAccountSchemaServer } from '../schemas/update-account.schema'; import { UpdateAccountSchemaServer } from '../schemas/update-account.schema';
@@ -15,6 +16,7 @@ export const onUpdateAccount = enhanceAction(
try { try {
await api.updateAccount(params); await api.updateAccount(params);
console.log('SUCCESS', pathsConfig.auth.updateAccountSuccess);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
console.warn('On update account error: ' + err.message); console.warn('On update account error: ' + err.message);

View File

@@ -44,7 +44,12 @@ async function VerifyPage(props: Props) {
!!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home; !!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
return ( return (
<MultiFactorChallengeContainer userId={user.id} paths={{ redirectPath }} /> <MultiFactorChallengeContainer
userId={user.id}
paths={{
redirectPath,
}}
/>
); );
} }

View File

@@ -2,6 +2,7 @@
import React, { ReactElement, ReactNode, useMemo, useState } from 'react'; import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
@@ -9,24 +10,29 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { AnalysisElement } from '~/lib/services/analysis-element.service'; import { AnalysisElement } from '~/lib/services/analysis-element.service';
import { NestedAnalysisElement } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import AnalysisLevelBar, { import AnalysisLevelBar, {
AnalysisLevelBarSkeleton, AnalysisLevelBarSkeleton,
AnalysisResultLevel, AnalysisResultLevel,
} from './analysis-level-bar'; } from './analysis-level-bar';
export type AnalysisResultForDisplay = { export type AnalysisResultForDisplay = Pick<
norm_status?: number | null; UserAnalysisElement,
response_value?: number | null; | 'norm_status'
unit?: string | null; | 'response_value'
norm_lower_included?: boolean | null; | 'unit'
norm_upper_included?: boolean | null; | 'norm_lower_included'
norm_lower?: number | null; | 'norm_upper_included'
norm_upper?: number | null; | 'norm_lower'
response_time?: string | null; | 'norm_upper'
nestedElements?: NestedAnalysisElement[]; | 'response_time'
}; >;
export enum AnalysisStatus {
NORMAL = 0,
MEDIUM = 1,
HIGH = 2,
}
const AnalysisDoctor = ({ const AnalysisDoctor = ({
analysisElement, analysisElement,
@@ -42,41 +48,38 @@ const AnalysisDoctor = ({
endIcon?: ReactNode | null; endIcon?: ReactNode | null;
}) => { }) => {
const name = analysisElement.analysis_name_lab || ''; const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status; const status = results?.norm_status || AnalysisStatus.NORMAL;
const value = results?.response_value || 0; const value = results?.response_value || 0;
const unit = results?.unit || ''; const unit = results?.unit || '';
const normLower = results?.norm_lower ?? null; const normLowerIncluded = results?.norm_lower_included || false;
const normUpper = results?.norm_upper ?? null; const normUpperIncluded = results?.norm_upper_included || false;
const normLower = results?.norm_lower || 0;
const normUpper = results?.norm_upper || 0;
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => { const analysisResultLevel = useMemo(() => {
if (!results || status === null || status === undefined) { if (!results) {
return null; return null;
} }
const isUnderNorm = value < normLower;
if (isUnderNorm) {
switch (status) { switch (status) {
case 1: case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.WARNING; return AnalysisResultLevel.LOW;
case 2: default:
return AnalysisResultLevel.CRITICAL; return AnalysisResultLevel.VERY_LOW;
case 0: }
}
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.HIGH;
case AnalysisStatus.HIGH:
return AnalysisResultLevel.VERY_HIGH;
default: default:
return AnalysisResultLevel.NORMAL; return AnalysisResultLevel.NORMAL;
} }
}, [results, status]); }, [results, value, normLower]);
const normRangeText = useMemo(() => {
if (normLower === null && normUpper === null) {
return null;
}
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
}, [normLower, normUpper]);
const nestedElements = results?.nestedElements ?? null;
const hasNestedElements =
Array.isArray(nestedElements) && nestedElements.length > 0;
const isAnalysisLevelBarHidden = isCancelled || !results || hasNestedElements;
return ( return (
<div className="border-border rounded-lg border px-5"> <div className="border-border rounded-lg border px-5">
@@ -107,41 +110,27 @@ const AnalysisDoctor = ({
</div> </div>
)} )}
</div> </div>
{isAnalysisLevelBarHidden ? null : ( {results ? (
<> <>
<div className="flex items-center gap-3 sm:ml-auto"> <div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div> <div className="font-semibold">{value}</div>
<div className="text-muted-foreground text-sm">{unit}</div> <div className="text-muted-foreground text-sm">{unit}</div>
</div> </div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0"> <div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
{normRangeText} {normLower} - {normUpper}
<div> <div>
<Trans i18nKey="analysis-results:results.range.normal" /> <Trans i18nKey="analysis-results:results.range.normal" />
</div> </div>
</div> </div>
<AnalysisLevelBar <AnalysisLevelBar
results={results} results={results}
level={analysisResultLevel} normLowerIncluded={normLowerIncluded}
normRangeText={normRangeText} normUpperIncluded={normUpperIncluded}
level={analysisResultLevel!}
/> />
{endIcon || <div className="mx-2 w-4" />} {endIcon || <div className="mx-2 w-4" />}
</> </>
)} ) : isCancelled ? null : (
{(() => {
// If parent has nested elements, don't show anything
if (hasNestedElements) {
return null;
}
// If we're showing the level bar, don't show waiting
if (!isAnalysisLevelBarHidden) {
return null;
}
// If cancelled, don't show waiting
if (isCancelled) {
return null;
}
// Otherwise, show waiting for results
return (
<> <>
<div className="flex items-center gap-3 sm:ml-auto"> <div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold"> <div className="font-semibold">
@@ -151,8 +140,7 @@ const AnalysisDoctor = ({
<div className="mx-8 w-[60px]"></div> <div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton /> <AnalysisLevelBarSkeleton />
</> </>
); )}
})()}
</div> </div>
</div> </div>
); );

View File

@@ -1,27 +0,0 @@
'use server';
import React from 'react';
import { Spinner } from '@kit/ui/makerkit/spinner';
import { Trans } from '@kit/ui/makerkit/trans';
import { Progress } from '@kit/ui/shadcn/progress';
import { withI18n } from '~/lib/i18n/with-i18n';
const AnalysisFallback = ({
progress,
progressTextKey,
}: {
progress: number;
progressTextKey: string;
}) => {
return (
<div className="flex flex-col items-center justify-center gap-4 py-10">
<Trans i18nKey={progressTextKey} />
<Spinner />
<Progress value={progress} />
</div>
);
};
export default withI18n(AnalysisFallback);

View File

@@ -1,177 +0,0 @@
import React, { useState } from 'react';
import { giveFeedbackAction } from '@/packages/features/doctor/src/lib/server/actions/doctor-server-actions';
import {
DoctorFeedback,
Order,
Patient,
} from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import {
DoctorAnalysisFeedbackForm,
doctorAnalysisFeedbackFormSchema,
} from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema';
import ConfirmationModal from '@/packages/shared/src/components/confirmation-modal';
import { useUser } from '@/packages/supabase/src/hooks/use-user';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { Spinner } from '@kit/ui/makerkit/spinner';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/shadcn/form';
import { toast } from '@kit/ui/shadcn/sonner';
import { Textarea } from '@kit/ui/shadcn/textarea';
const AnalysisFeedback = ({
feedback,
patient,
order,
aiDoctorFeedback,
timestamp,
recommendations,
isRecommendationsEdited,
}: {
feedback?: DoctorFeedback;
patient: Patient;
order: Order;
aiDoctorFeedback?: string;
timestamp?: string;
recommendations: string[];
isRecommendationsEdited: boolean;
}) => {
const [isDraftSubmitting, setIsDraftSubmitting] = useState(false);
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const { data: user } = useUser();
const queryClient = useQueryClient();
const form = useForm({
resolver: zodResolver(doctorAnalysisFeedbackFormSchema),
reValidateMode: 'onChange',
defaultValues: {
feedbackValue: feedback?.value ?? aiDoctorFeedback ?? '',
userId: patient.userId,
},
});
const isReadOnly =
!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id;
const handleDraftSubmit = async (e: React.FormEvent) => {
setIsDraftSubmitting(true);
e.preventDefault();
form.formState.errors.feedbackValue = undefined;
const formData = form.getValues();
await onSubmit(formData, 'DRAFT');
setIsDraftSubmitting(false);
};
const handleCompleteSubmit = form.handleSubmit(async () => {
setIsConfirmOpen(true);
});
const onSubmit = async (
data: DoctorAnalysisFeedbackForm,
status: 'DRAFT' | 'COMPLETED',
) => {
setIsConfirmOpen(false);
setIsSubmittingFeedback(true);
const result = await giveFeedbackAction({
...data,
analysisOrderId: order.analysisOrderId,
status,
patientId: patient.userId,
timestamp,
recommendations,
isRecommendationsEdited,
});
if (!result.success) {
return toast.error(<Trans i18nKey="common:genericServerError" />);
}
setIsSubmittingFeedback(false);
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
});
return toast.success(<Trans i18nKey={'doctor:updateFeedbackSuccess'} />);
};
const confirmComplete = form.handleSubmit(async (data) => {
await onSubmit(data, 'COMPLETED');
});
return (
<>
{!isReadOnly && (
<Form {...form}>
<form className="space-y-4 lg:w-1/2">
<FormField
control={form.control}
name="feedbackValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
className="min-h-[200px]"
{...field}
disabled={isDraftSubmitting || isSubmittingFeedback}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="xs:flex block justify-end gap-2 space-y-2">
<Button
type="button"
variant="outline"
onClick={handleDraftSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-auto w-full text-xs"
>
<Trans i18nKey="common:saveAsDraft" />
</Button>
<Button
type="button"
onClick={handleCompleteSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full"
>
{isDraftSubmitting || form.formState.isSubmitting ? (
<Spinner />
) : (
<Trans i18nKey="common:save" />
)}
</Button>
</div>
</form>
</Form>
)}
<ConfirmationModal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={confirmComplete}
titleKey="doctor:confirmFeedbackModal.title"
descriptionKey="doctor:confirmFeedbackModal.description"
/>
</>
);
};
export default AnalysisFeedback;

View File

@@ -7,9 +7,11 @@ import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis-doctor'; import { AnalysisResultForDisplay } from './analysis-doctor';
export enum AnalysisResultLevel { export enum AnalysisResultLevel {
NORMAL = 'NORMAL', VERY_LOW = 0,
WARNING = 'WARNING', LOW = 1,
CRITICAL = 'CRITICAL', NORMAL = 2,
HIGH = 3,
VERY_HIGH = 4,
} }
const Level = ({ const Level = ({
@@ -18,19 +20,17 @@ const Level = ({
isFirst = false, isFirst = false,
isLast = false, isLast = false,
arrowLocation, arrowLocation,
normRangeText,
}: { }: {
isActive?: boolean; isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200'; color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean; isFirst?: boolean;
isLast?: boolean; isLast?: boolean;
arrowLocation?: number; arrowLocation?: number;
normRangeText?: string | null;
}) => { }) => {
return ( return (
<div <div
className={cn(`bg-${color} relative h-3 flex-1`, { className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-60': !isActive, 'opacity-20': !isActive,
'rounded-l-lg': isFirst, 'rounded-l-lg': isFirst,
'rounded-r-lg': isLast, 'rounded-r-lg': isLast,
})} })}
@@ -38,32 +38,11 @@ const Level = ({
{isActive && ( {isActive && (
<div <div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]" className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
{...(arrowLocation style={{ left: `${arrowLocation}%` }}
? {
style: {
left: `${arrowLocation}%`,
...(arrowLocation > 92.5 && { left: '92.5%' }),
...(arrowLocation < 7.5 && { left: '7.5%' }),
},
}
: {})}
> >
<ArrowDown strokeWidth={2} /> <ArrowDown strokeWidth={2} />
</div> </div>
)} )}
{color === 'success' && typeof normRangeText === 'string' && (
<p
className={cn(
'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap',
{
'opacity-60': isActive,
},
)}
>
{normRangeText}
</p>
)}
</div> </div>
); );
}; };
@@ -71,148 +50,81 @@ const Level = ({
export const AnalysisLevelBarSkeleton = () => { export const AnalysisLevelBarSkeleton = () => {
return ( return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]"> <div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast /> <Level color="gray-200" />
</div> </div>
); );
}; };
const AnalysisLevelBar = ({ const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level, level,
results, results,
normRangeText,
}: { }: {
level: AnalysisResultLevel | null; normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel;
results: AnalysisResultForDisplay; results: AnalysisResultForDisplay;
normRangeText: string | null;
}) => { }) => {
const { const {
norm_lower: lower, norm_lower: lower,
norm_upper: upper, norm_upper: upper,
response_value: value, response_value: value,
} = results; } = results;
// Calculate arrow position based on value within normal range
const arrowLocation = useMemo(() => { const arrowLocation = useMemo(() => {
// If no response value, center the arrow
if (value === null || value === undefined) {
return 50;
}
// If no normal ranges defined, center the arrow
if (lower === null && upper === null) {
return 50;
}
// If only upper bound exists
if (lower === null && upper !== null) {
if (value <= upper!) {
return Math.min(75, (value / upper!) * 75); // Show in left 75% of normal range
}
return 100; // Beyond upper bound
}
// If only lower bound exists
if (upper === null && lower !== null) {
if (value >= lower!) {
// Value is in normal range (above lower bound)
// Position proportionally in the normal range section
const normalizedPosition = Math.min(
(value - lower!) / (lower! * 0.5),
1,
); // Use 50% of lower as scale
return normalizedPosition * 100;
}
// Value is below lower bound - position in the "below normal" section
const belowPosition = Math.max(0, Math.min(1, value / lower!));
return belowPosition * 100;
}
// Both bounds exist
if (lower !== null && upper !== null) {
if (value < lower!) { if (value < lower!) {
return 0; // Below normal range return 0;
}
if (value > upper!) {
return 100; // Above normal range
}
// Within normal range
return ((value - lower!) / (upper! - lower!)) * 100;
} }
return 50; // Fallback if (normLowerIncluded || normUpperIncluded) {
return 50;
}
const calculated = ((value - lower!) / (upper! - lower!)) * 100;
if (calculated > 100) {
return 100;
}
return calculated;
}, [value, upper, lower]); }, [value, upper, lower]);
// Determine level states based on normStatus const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(
const isNormal = level === AnalysisResultLevel.NORMAL; () => [
const isWarning = level === AnalysisResultLevel.WARNING; level === AnalysisResultLevel.VERY_LOW,
const isCritical = level === AnalysisResultLevel.CRITICAL; level === AnalysisResultLevel.LOW,
const isPending = level === null; level === AnalysisResultLevel.HIGH,
level === AnalysisResultLevel.VERY_HIGH,
// Show appropriate levels based on available norm bounds ],
const hasLowerBound = lower !== null; [level, value, upper, lower],
// Calculate level configuration (must be called before any returns)
const [first, second, third] = useMemo(() => {
const [warning, normal, critical] = [
{
isActive: isWarning,
color: 'warning',
...(isWarning ? { arrowLocation } : {}),
},
{
isActive: isNormal,
color: 'success',
normRangeText,
...(isNormal ? { arrowLocation } : {}),
},
{
isActive: isCritical,
color: 'destructive',
isLast: true,
...(isCritical ? { arrowLocation } : {}),
},
] as const;
if (!hasLowerBound) {
return [{ ...normal, isFirst: true }, warning, critical] as const;
}
return [
{ ...warning, isFirst: true },
normal,
{ ...critical, isLast: true },
] as const;
}, [
arrowLocation,
normRangeText,
isNormal,
isWarning,
isCritical,
hasLowerBound,
]);
// If pending results, show gray bar
if (isPending) {
return (
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
); );
}
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
return ( return (
<div <div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
className={cn( {normLowerIncluded && (
'flex h-3 gap-1', <>
'mt-4 sm:mt-0', <Level isActive={isVeryLow} color="destructive" isFirst />
'w-[60%] sm:w-[35%]', <Level isActive={isLow} color="warning" />
'min-w-[50vw] sm:min-w-auto', </>
'max-w-[360px]', )}
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
{...(hasAbnormalLevel
? { color: 'warning', isActive: false }
: { color: 'success', isActive: true })}
arrowLocation={arrowLocation}
/>
{normUpperIncluded && (
<>
<Level isActive={isHigh} color="warning" />
<Level isActive={isVeryHigh} color="destructive" isLast />
</>
)} )}
>
<Level {...first} />
<Level {...second} />
<Level {...third} />
</div> </div>
); );
}; };

View File

@@ -1,10 +1,13 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { capitalize } from 'lodash'; import { capitalize } from 'lodash';
import { useForm } from 'react-hook-form';
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
import { import {
getDOBWithAgeStringFromPersonalCode, getDOBWithAgeStringFromPersonalCode,
getResultSetName, getResultSetName,
@@ -15,50 +18,46 @@ import {
Order, Order,
Patient, Patient,
} from '@kit/doctor/schema/doctor-analysis-detail-view.schema'; } from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
import {
DoctorAnalysisFeedbackForm,
doctorAnalysisFeedbackFormSchema,
} from '@kit/doctor/schema/doctor-analysis.schema';
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks'; import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { bmiFromMetric } from '~/lib/utils'; import { bmiFromMetric } from '~/lib/utils';
import AnalysisFeedback from './analysis-feedback';
import DoctorAnalysisWrapper from './doctor-analysis-wrapper'; import DoctorAnalysisWrapper from './doctor-analysis-wrapper';
import DoctorJobSelect from './doctor-job-select'; import DoctorJobSelect from './doctor-job-select';
import DoctorRecommendedAnalyses from './doctor-recommended-analyses';
export default function AnalysisView({ export default function AnalysisView({
patient, patient,
order, order,
analyses, analyses,
feedback, feedback,
aiDoctorFeedback,
recommendations,
availableAnalyses,
timestamp,
}: { }: {
patient: Patient; patient: Patient;
order: Order; order: Order;
analyses: AnalysisResponse[]; analyses: AnalysisResponse[];
feedback?: DoctorFeedback; feedback?: DoctorFeedback;
aiDoctorFeedback?: string;
recommendations?: string[];
availableAnalyses?: string[];
timestamp?: string;
}) { }) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [isDraftSubmitting, setIsDraftSubmitting] = useState(false);
const { data: user } = useUser(); const { data: user } = useUser();
const queryClient = useQueryClient();
const [recommendedAnalyses, setRecommendedAnalyses] = useState<string[]>(
recommendations ?? [],
);
const isRecommendationsEdited = useMemo(() => {
if (recommendedAnalyses.length !== recommendations?.length) return true;
const sa = new Set(recommendedAnalyses),
sb = new Set(recommendations);
if (sa.size !== sb.size) return true;
for (const v of sa) if (!sb.has(v)) return true;
return false;
}, [recommendations, recommendedAnalyses]);
const languageNames = useCurrentLocaleLanguageNames(); const languageNames = useCurrentLocaleLanguageNames();
@@ -69,11 +68,66 @@ export default function AnalysisView({
); );
const isCurrentDoctorJob = const isCurrentDoctorJob =
!!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id; !!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id;
const isReadOnly =
!isInProgress ||
(!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id);
const form = useForm({
resolver: zodResolver(doctorAnalysisFeedbackFormSchema),
reValidateMode: 'onChange',
defaultValues: {
feedbackValue: feedback?.value ?? '',
userId: patient.userId,
},
});
const queryClient = useQueryClient();
if (!patient || !order || !analyses) { if (!patient || !order || !analyses) {
return null; return null;
} }
const onSubmit = async (
data: DoctorAnalysisFeedbackForm,
status: 'DRAFT' | 'COMPLETED',
) => {
const result = await giveFeedbackAction({
...data,
analysisOrderId: order.analysisOrderId,
status,
});
if (!result.success) {
return toast.error(<Trans i18nKey="common:genericServerError" />);
}
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
});
toast.success(<Trans i18nKey={'doctor:updateFeedbackSuccess'} />);
return setIsConfirmOpen(false);
};
const handleDraftSubmit = async (e: React.FormEvent) => {
setIsDraftSubmitting(true);
e.preventDefault();
form.formState.errors.feedbackValue = undefined;
const formData = form.getValues();
await onSubmit(formData, 'DRAFT');
setIsDraftSubmitting(false);
};
const handleCompleteSubmit = form.handleSubmit(async () => {
setIsConfirmOpen(true);
});
const confirmComplete = form.handleSubmit(async (data) => {
await onSubmit(data, 'COMPLETED');
});
return ( return (
<> <>
<div className="xs:flex xs:justify-between"> <div className="xs:flex xs:justify-between">
@@ -175,30 +229,59 @@ export default function AnalysisView({
); );
})} })}
</div> </div>
{order.isPackage && (
<>
<h3> <h3>
<Trans i18nKey="doctor:feedback" /> <Trans i18nKey="doctor:feedback" />
</h3> </h3>
<p>{feedback?.value ?? '-'}</p> <p>{feedback?.value ?? '-'}</p>
<div className="flex flex-col gap-4 lg:flex-row"> {!isReadOnly && (
<AnalysisFeedback <Form {...form}>
order={order} <form className="space-y-4 lg:w-1/2">
patient={patient} <FormField
feedback={feedback} control={form.control}
aiDoctorFeedback={aiDoctorFeedback} name="feedbackValue"
timestamp={timestamp} render={({ field }) => (
recommendations={recommendedAnalyses} <FormItem>
isRecommendationsEdited={isRecommendationsEdited} <FormControl>
/> <Textarea {...field} disabled={isReadOnly} />
<DoctorRecommendedAnalyses </FormControl>
recommendedAnalyses={recommendedAnalyses} <FormMessage />
availableAnalyses={availableAnalyses} </FormItem>
setRecommendedAnalyses={setRecommendedAnalyses}
/>
</div>
</>
)} )}
/>
<div className="xs:flex block justify-end gap-2 space-y-2">
<Button
type="button"
variant="outline"
onClick={handleDraftSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full"
>
<Trans i18nKey="common:saveAsDraft" />
</Button>
<Button
type="button"
onClick={handleCompleteSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full"
>
<Trans i18nKey="common:save" />
</Button>
</div>
</form>
</Form>
)}
<ConfirmationModal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={confirmComplete}
titleKey="doctor:confirmFeedbackModal.title"
descriptionKey="doctor:confirmFeedbackModal.description"
/>
</> </>
); );
} }

View File

@@ -1,7 +1,5 @@
'use client'; 'use client';
import React from 'react';
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons'; import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -25,7 +23,6 @@ export default function DoctorAnalysisWrapper({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<>
<Collapsible className="w-full" key={analysisData.id}> <Collapsible className="w-full" key={analysisData.id}>
<CollapsibleTrigger <CollapsibleTrigger
disabled={!analysisData.latestPreviousAnalysis} disabled={!analysisData.latestPreviousAnalysis}
@@ -67,7 +64,7 @@ export default function DoctorAnalysisWrapper({
</CollapsibleTrigger> </CollapsibleTrigger>
{analysisData.latestPreviousAnalysis && ( {analysisData.latestPreviousAnalysis && (
<CollapsibleContent> <CollapsibleContent>
<div className="my-1 flex flex-col gap-2"> <div className="my-1 flex flex-col">
<AnalysisDoctor <AnalysisDoctor
endIcon={ endIcon={
analysisData.latestPreviousAnalysis.comment && ( analysisData.latestPreviousAnalysis.comment && (
@@ -92,62 +89,15 @@ export default function DoctorAnalysisWrapper({
analysisElement={{ analysisElement={{
analysis_name_lab: t('doctor:previousResults', { analysis_name_lab: t('doctor:previousResults', {
date: formatDate( date: formatDate(
analysisData.latestPreviousAnalysis.response_time!, analysisData.latestPreviousAnalysis.response_time,
), ),
}), }),
}} }}
results={analysisData.latestPreviousAnalysis} results={analysisData.latestPreviousAnalysis}
/> />
{analysisData.latestPreviousAnalysis.nestedElements?.map(
(nestedElement, nestedIndex) => (
<div
key={`prev-nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
className="ml-8"
>
<AnalysisDoctor
analysisElement={{
analysis_name_lab: nestedElement.analysisNameLab ?? '',
}}
results={{
norm_status: nestedElement.normStatus,
response_value: nestedElement.responseValue,
unit: nestedElement.unit,
norm_lower: nestedElement.normLower,
norm_upper: nestedElement.normUpper,
norm_lower_included: nestedElement.normLowerIncluded,
norm_upper_included: nestedElement.normUpperIncluded,
response_time: nestedElement.responseTime,
}}
/>
</div>
),
)}
</div> </div>
</CollapsibleContent> </CollapsibleContent>
)} )}
</Collapsible> </Collapsible>
{analysisData.nestedElements?.map((nestedElement, nestedIndex) => (
<div
key={`nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
className="ml-8"
>
<AnalysisDoctor
analysisElement={{
analysis_name_lab: nestedElement.analysisNameLab ?? '',
}}
results={{
norm_status: nestedElement.normStatus,
response_value: nestedElement.responseValue,
unit: nestedElement.unit,
norm_lower: nestedElement.normLower,
norm_upper: nestedElement.normUpper,
norm_lower_included: nestedElement.normLowerIncluded,
norm_upper_included: nestedElement.normUpperIncluded,
response_time: nestedElement.responseTime,
}}
/>
</div>
))}
</>
); );
} }

View File

@@ -9,7 +9,7 @@ import {
import ResultsTableWrapper from './results-table-wrapper'; import ResultsTableWrapper from './results-table-wrapper';
export default function DoctorDashboard() { export default function Dashboard() {
return ( return (
<> <>
<ResultsTableWrapper <ResultsTableWrapper

View File

@@ -1,53 +0,0 @@
'use client';
import React, { Dispatch, SetStateAction } from 'react';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
const DoctorRecommendedAnalyses = ({
recommendedAnalyses,
availableAnalyses,
setRecommendedAnalyses,
}: {
recommendedAnalyses?: string[];
availableAnalyses?: string[];
setRecommendedAnalyses: Dispatch<SetStateAction<string[]>>;
}) => {
if (availableAnalyses?.length === 0) {
return null;
}
return (
<div className="w-1/3">
<h5>
<Trans i18nKey="doctor:recommendedAnalyses" />
</h5>
<div className="mt-4 flex flex-wrap gap-2">
{availableAnalyses?.map((analysis, index) => {
return (
<Button
size="sm"
key={`${index}-analysis-feedback-list`}
variant={
recommendedAnalyses?.includes(analysis) ? 'default' : 'outline'
}
type="button"
onClick={() =>
setRecommendedAnalyses((prev: string[]) =>
prev.includes(analysis)
? prev.filter((x) => x !== analysis)
: [...prev, analysis],
)
}
>
{analysis}
</Button>
);
})}
</div>
</div>
);
};
export default DoctorRecommendedAnalyses;

View File

@@ -34,7 +34,7 @@ export function DoctorSidebar({
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader className={'m-2'}> <SidebarHeader className={'m-2'}>
<AppLogo <AppLogo
href={pathsConfig.app.home} href={pathsConfig.app.doctor}
className="max-w-full" className="max-w-full"
compact={!open} compact={!open}
/> />

View File

@@ -1,90 +0,0 @@
'use server';
import React from 'react';
import { AnalysisResponses } from '@/app/home/(user)/_components/ai/types';
import { OrderAnalysisCard } from '@/app/home/(user)/_components/order-analyses-cards';
import { loadLifeStyle } from '@/app/home/(user)/_lib/server/load-life-style';
import { loadRecommendations } from '@/app/home/(user)/_lib/server/load-recommendations';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { AnalysisResultDetails } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import { withI18n } from '~/lib/i18n/with-i18n';
import {
loadDoctorFeedback,
prepareFeedback,
} from '../_lib/server/load-doctor-feedback';
import AnalysisView from './analysis-view';
async function NewAnalysisRecommendationsLoader({
analysisResultDetails,
account,
analysisResponses,
currentAIResponseTimestamp,
analyses,
patient,
}: {
currentAIResponseTimestamp: string;
account: AccountWithParams | null;
analysisResponses: AnalysisResponses;
analysisResultDetails: AnalysisResultDetails;
analyses: OrderAnalysisCard[];
patient: AccountWithParams | null;
}) {
if (!analysisResultDetails.order.isPackage) {
return (
<AnalysisView
patient={analysisResultDetails.patient}
order={analysisResultDetails.order}
analyses={analysisResultDetails.analysisResponse}
feedback={analysisResultDetails.doctorFeedback}
/>
);
}
const [lifeStyle, recommendations, aiFeedback] = await Promise.all([
loadLifeStyle({
account: patient,
analysisResponses,
isDoctorView: true,
aiResponseTimestamp: currentAIResponseTimestamp,
}),
loadRecommendations({
account: patient,
analysisResponses,
aiResponseTimestamp: currentAIResponseTimestamp,
isDoctorView: true,
analyses,
}),
loadDoctorFeedback(
analysisResultDetails.patient,
analysisResultDetails.analysisResponse,
currentAIResponseTimestamp,
),
]);
const feedback = prepareFeedback({
aiResponse: aiFeedback,
recommendations,
lifeStyleSummary: lifeStyle.response.summary,
patientName: analysisResultDetails.patient.firstName,
doctorName: `${account?.name} ${account?.last_name}`,
aiResponseTimestamp: currentAIResponseTimestamp,
});
return (
<AnalysisView
patient={analysisResultDetails.patient}
order={analysisResultDetails.order}
analyses={analysisResultDetails.analysisResponse}
feedback={analysisResultDetails.doctorFeedback}
aiDoctorFeedback={feedback}
availableAnalyses={analyses.map((analysis) => analysis.title)}
recommendations={recommendations}
timestamp={currentAIResponseTimestamp}
/>
);
}
export default withI18n(NewAnalysisRecommendationsLoader);

View File

@@ -1,55 +0,0 @@
'use server';
import React, { Suspense } from 'react';
import { loadAnalyses } from '@/app/home/(user)/_lib/server/load-analyses';
import {
loadCurrentUserAccount,
loadUserAccount,
} from '@/app/home/(user)/_lib/server/load-user-account';
import { AnalysisResultDetails } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getLatestResponseTime } from '~/lib/utils';
import AnalysisFallback from './analysis-fallback';
import NewAnalysisRecommendationsLoader from './new-analysis-recommendations-loader';
async function PrepareAIParameters({
analysisResultDetails,
}: {
analysisResultDetails: AnalysisResultDetails;
}) {
const { analyses } = await loadAnalyses();
const { account: doctorAccount } = await loadCurrentUserAccount();
const patientAccount = await loadUserAccount(
analysisResultDetails.patient.userId,
);
const client = getSupabaseServerClient();
const userAnalysesApi = createUserAnalysesApi(client);
const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses(
patientAccount.id,
);
const currentAIResponseTimestamp = getLatestResponseTime(analysisResponses);
return (
<Suspense
fallback={
<AnalysisFallback progress={66} progressTextKey="doctor:loadFeedback" />
}
>
<NewAnalysisRecommendationsLoader
account={doctorAccount}
currentAIResponseTimestamp={currentAIResponseTimestamp}
analysisResponses={analysisResponses}
analysisResultDetails={analysisResultDetails}
analyses={analyses}
patient={patientAccount}
/>
</Suspense>
);
}
export default withI18n(PrepareAIParameters);

View File

@@ -178,7 +178,7 @@ export default function ResultsTable({
<TableCell> <TableCell>
<Trans <Trans
i18nKey={ i18nKey={
resultsReceived >= elementsInOrder resultsReceived === elementsInOrder
? 'doctor:resultsTable.responsesReceived' ? 'doctor:resultsTable.responsesReceived'
: 'doctor:resultsTable.waitingForNr' : 'doctor:resultsTable.waitingForNr'
} }

View File

@@ -1,98 +0,0 @@
import { cache } from 'react';
import { PROMPT_NAME } from '@/app/home/(user)/_components/ai/types';
import { generateDoctorFeedback } from '@/app/home/(user)/_lib/server/ai-actions';
import {
AnalysisResponse,
Patient,
} from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import { getLogger } from '@/packages/shared/src/logger';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
export const loadDoctorFeedback = cache(doctorFeedbackLoader);
const PLACEHOLDER = {
ANALYSES: 'SOOVITATUD_ANALYYSID_PLACEHOLDER',
LIFE_STYLE_SUMMARY: 'ELUSTIILI_KOKKUVOTTE_PLACEHOLDER',
PATIENT_NAME: 'PATSIENDI_NIMI_PLACEHOLDER',
DOCTOR_NAME: 'ARSTI_NIMI_PLACEHOLDER',
ANALYSES_DATE: 'ANALYYSI_KUUPAEV_PLACEHOLDER',
};
export const prepareFeedback = ({
aiResponse,
recommendations,
lifeStyleSummary,
patientName,
doctorName,
aiResponseTimestamp,
}: {
aiResponse: string;
recommendations?: string[];
lifeStyleSummary: string | null;
patientName: string;
doctorName: string;
aiResponseTimestamp: string;
}) => {
const recommendationsList = recommendations
? recommendations.map((analysis) => `${analysis}`).join('\n')
: '';
const feedback = aiResponse
.replace(PLACEHOLDER.ANALYSES, recommendationsList)
.replace(PLACEHOLDER.LIFE_STYLE_SUMMARY, lifeStyleSummary ?? '')
.replace(PLACEHOLDER.PATIENT_NAME, patientName)
.replace(PLACEHOLDER.DOCTOR_NAME, doctorName)
.replace(
PLACEHOLDER.ANALYSES_DATE,
new Date(aiResponseTimestamp).toLocaleString(),
);
return feedback;
};
async function doctorFeedbackLoader(
patient: Patient | null,
analysisResponses: AnalysisResponse[],
aiResponseTimestamp: string,
): Promise<string> {
const logger = await getLogger();
if (!patient?.personalCode) {
return '';
}
const supabaseClient = getSupabaseServerClient();
logger.info(
{
aiResponseTimestamp,
patientId: patient.userId,
},
'Attempting to receive existing doctor feedback',
);
const { data, error } = await supabaseClient
.schema('medreport')
.from('ai_responses')
.select('*')
.eq('account_id', patient.userId)
.eq('prompt_name', PROMPT_NAME.FEEDBACK)
.eq('latest_data_change', aiResponseTimestamp)
.limit(1)
.maybeSingle();
logger.info({ data: !!data }, 'Existing doctor feedback');
if (error) {
console.error('Error fetching AI response from DB: ', error);
return '';
}
if (data?.response) {
return data.response as string;
} else {
return await generateDoctorFeedback({
patient,
analysisResponses,
aiResponseTimestamp,
});
}
}

View File

@@ -1,17 +1,15 @@
import { Suspense, cache } from 'react'; import { cache } from 'react';
import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service'; import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
import { import {
DoctorPageViewAction, DoctorPageViewAction,
createDoctorPageViewLog, createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service'; } from '~/lib/services/audit/doctorPageView.service';
import AnalysisFallback from '../../_components/analysis-fallback'; import AnalysisView from '../../_components/analysis-view';
import { DoctorGuard } from '../../_components/doctor-guard'; import { DoctorGuard } from '../../_components/doctor-guard';
import PrepareAiParameters from '../../_components/prepare-ai-parameters';
async function AnalysisPage({ async function AnalysisPage({
params, params,
@@ -38,21 +36,17 @@ async function AnalysisPage({
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody className="px-12"> <PageBody>
<Suspense <AnalysisView
fallback={ patient={analysisResultDetails.patient}
<AnalysisFallback order={analysisResultDetails.order}
progress={33} analyses={analysisResultDetails.analysisResponse}
progressTextKey="doctor:loadParameters" feedback={analysisResultDetails.doctorFeedback}
/> />
}
>
<PrepareAiParameters analysisResultDetails={analysisResultDetails} />
</Suspense>
</PageBody> </PageBody>
</> </>
); );
} }
export default DoctorGuard(withI18n(AnalysisPage)); export default DoctorGuard(AnalysisPage);
const loadResult = cache(getAnalysisResultsForDoctor); const loadResult = cache(getAnalysisResultsForDoctor);

View File

@@ -17,7 +17,7 @@ async function CompletedJobsPage() {
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody className="px-12"> <PageBody>
<ResultsTableWrapper <ResultsTableWrapper
titleKey="doctor:completedReviews" titleKey="doctor:completedReviews"
action={getUserDoneResponsesAction} action={getUserDoneResponsesAction}

View File

@@ -17,7 +17,7 @@ async function MyReviewsPage() {
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody className="px-12"> <PageBody>
<ResultsTableWrapper <ResultsTableWrapper
titleKey="doctor:myReviews" titleKey="doctor:myReviews"
action={getUserInProgressResponsesAction} action={getUserInProgressResponsesAction}

View File

@@ -17,7 +17,7 @@ async function OpenJobsPage() {
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody className="px-12"> <PageBody>
<ResultsTableWrapper <ResultsTableWrapper
titleKey="doctor:openReviews" titleKey="doctor:openReviews"
action={getOpenResponsesAction} action={getOpenResponsesAction}

View File

@@ -5,7 +5,7 @@ import {
createDoctorPageViewLog, createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service'; } from '~/lib/services/audit/doctorPageView.service';
import DoctorDashboard from './_components/doctor-dashboard'; import Dashboard from './_components/doctor-dashboard';
import { DoctorGuard } from './_components/doctor-guard'; import { DoctorGuard } from './_components/doctor-guard';
async function DoctorPage() { async function DoctorPage() {
@@ -16,8 +16,8 @@ async function DoctorPage() {
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody className="px-12"> <PageBody>
<DoctorDashboard /> <Dashboard />
</PageBody> </PageBody>
</> </>
); );

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react'; import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -21,22 +20,6 @@ const ErrorPage = ({
}) => { }) => {
useCaptureException(error); useCaptureException(error);
// Ignore next.js internal transient navigation errors that occur during auth state changes
const isTransientNavigationError =
error?.message?.includes('Error in input stream') ||
error?.message?.includes('AbortError') ||
(error?.name === 'ChunkLoadError');
useEffect(() => {
if (isTransientNavigationError && typeof window !== 'undefined') {
window.location.href = '/';
}
}, [isTransientNavigationError]);
if (isTransientNavigationError) {
return <div />;
}
return ( return (
<div className={'flex h-screen flex-1 flex-col'}> <div className={'flex h-screen flex-1 flex-col'}>
<SiteHeader /> <SiteHeader />

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react'; import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -22,22 +21,6 @@ const GlobalErrorPage = ({
}) => { }) => {
useCaptureException(error); useCaptureException(error);
// Ignore next.js internal transient navigation errors that occur during auth state changes
const isTransientNavigationError =
error?.message?.includes('Error in input stream') ||
error?.message?.includes('AbortError') ||
(error?.name === 'ChunkLoadError');
useEffect(() => {
if (isTransientNavigationError && typeof window !== 'undefined') {
window.location.href = '/';
}
}, [isTransientNavigationError]);
if (isTransientNavigationError) {
return <div />;
}
return ( return (
<html> <html>
<body> <body>

View File

@@ -3,15 +3,11 @@ import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { createNotificationsApi } from '@/packages/features/notifications/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip'; import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { formatDateAndTime } from '@kit/shared/utils';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account'; import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis'; import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
@@ -29,9 +25,7 @@ export default async function AnalysisResultsPage({
id: string; id: string;
}>; }>;
}) { }) {
const supabaseClient = getSupabaseServerClient();
const { id: analysisOrderId } = await params; const { id: analysisOrderId } = await params;
const notificationsApi = createNotificationsApi(supabaseClient);
const [{ account }, analysisResponse] = await Promise.all([ const [{ account }, analysisResponse] = await Promise.all([
loadCurrentUserAccount(), loadCurrentUserAccount(),
@@ -47,11 +41,6 @@ export default async function AnalysisResultsPage({
action: PageViewAction.VIEW_ANALYSIS_RESULTS, action: PageViewAction.VIEW_ANALYSIS_RESULTS,
}); });
await notificationsApi.dismissNotification(
`/home/analysis-results/${analysisOrderId}`,
'link',
);
if (!analysisResponse) { if (!analysisResponse) {
return ( return (
<> <>
@@ -104,14 +93,7 @@ export default async function AnalysisResultsPage({
<h6> <h6>
<Trans i18nKey={`orders:status.${analysisResponse.order.status}`} /> <Trans i18nKey={`orders:status.${analysisResponse.order.status}`} />
<ButtonTooltip <ButtonTooltip
content={ content={`${analysisResponse.order.createdAt ? new Date(analysisResponse?.order?.createdAt).toLocaleString() : ''}`}
<Trans
i18nKey="analysis-results:orderCreatedAt"
values={{
createdAt: formatDateAndTime(analysisResponse.order.createdAt)
}}
/>
}
className="ml-6" className="ml-6"
/> />
</h6> </h6>
@@ -126,7 +108,7 @@ export default async function AnalysisResultsPage({
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{orderedAnalysisElements ? ( {orderedAnalysisElements ? (
orderedAnalysisElements.map((element) => ( orderedAnalysisElements.map((element, index) => (
<React.Fragment key={element.analysisIdOriginal}> <React.Fragment key={element.analysisIdOriginal}>
<Analysis element={element} /> <Analysis element={element} />
{element.results?.nestedElements?.map( {element.results?.nestedElements?.map(

View File

@@ -1,3 +1,5 @@
import { use } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server';

View File

@@ -3,7 +3,6 @@ import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { retrieveCart } from '@lib/data/cart'; import { retrieveCart } from '@lib/data/cart';
import { listProductTypes } from '@lib/data/products'; import { listProductTypes } from '@lib/data/products';
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
@@ -12,8 +11,9 @@ import { findProductTypeIdByHandle } from '~/lib/utils';
import Cart from '../../_components/cart'; import Cart from '../../_components/cart';
import CartTimer from '../../_components/cart/cart-timer'; import CartTimer from '../../_components/cart/cart-timer';
import { EnrichedCartItem } from '../../_components/cart/types';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account'; 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() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -24,7 +24,11 @@ export async function generateMetadata() {
} }
async function CartPage() { async function CartPage() {
const [cart, { productTypes }, { account }] = await Promise.all([ const [
cart,
{ productTypes },
{ account },
] = await Promise.all([
retrieveCart(), retrieveCart(),
listProductTypes(), listProductTypes(),
loadCurrentUserAccount(), loadCurrentUserAccount(),
@@ -34,9 +38,7 @@ async function CartPage() {
return null; return null;
} }
const balanceSummary = await new AccountBalanceService().getBalanceSummary( const balanceSummary = await new AccountBalanceService().getBalanceSummary(account.id);
account.id,
);
const synlabAnalysisTypeId = findProductTypeIdByHandle( const synlabAnalysisTypeId = findProductTypeIdByHandle(
productTypes, productTypes,

View File

@@ -1,77 +0,0 @@
import React from 'react';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { PageBody } from '@kit/ui/makerkit/page';
import { Trans } from '@kit/ui/makerkit/trans';
import { Skeleton } from '@kit/ui/shadcn/skeleton';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import { getLatestResponseTime } from '~/lib/utils';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import { loadLifeStyle } from '../../_lib/server/load-life-style';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('common:lifeStyle.title'),
};
}
async function LifeStylePage() {
const { account } = await loadCurrentUserAccount();
if (!account) {
return null;
}
const client = getSupabaseServerClient();
const userAnalysesApi = createUserAnalysesApi(client);
const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses();
const currentAIResponseTimestamp = getLatestResponseTime(analysisResponses);
const { response } = await loadLifeStyle({
account,
analysisResponses,
aiResponseTimestamp: currentAIResponseTimestamp,
});
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_LIFE_STYLE,
});
if (!response.lifestyle) {
return <Skeleton className="mt-10 h-10 w-full" />;
}
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common:lifeStyle.title'} />}
description=""
/>
<PageBody>
<div className="mt-8">
{response.lifestyle.map(({ title, description }, index) => (
<React.Fragment key={`${index}-${title}`}>
<div className="flex items-center gap-2">
<h3>{title}</h3>
</div>
<p className="font-regular py-4">{description}</p>
</React.Fragment>
))}
</div>
</PageBody>
</>
);
}
export default withI18n(LifeStylePage);

View File

@@ -28,18 +28,17 @@ async function OrderAnalysisPackagePage() {
<PageBody> <PageBody>
<div className="space-y-3 text-center"> <div className="space-y-3 text-center">
<h3> <h3>
<Trans i18nKey="order-analysis-package:selectPackage" /> <Trans i18nKey={'marketing:selectPackage'} />
</h3> </h3>
<ComparePackagesModal <ComparePackagesModal
analysisPackages={analysisPackages} analysisPackages={analysisPackages}
analysisPackageElements={analysisPackageElements} analysisPackageElements={analysisPackageElements}
triggerElement={ triggerElement={
<Button variant="secondary" className="gap-2"> <Button variant="secondary" className="gap-2">
<Trans i18nKey="order-analysis-package:comparePackages" /> <Trans i18nKey={'marketing:comparePackages'} />
<Scale className="size-4 stroke-[1.5px]" /> <Scale className="size-4 stroke-[1.5px]" />
</Button> </Button>
} }
countryCode={countryCode}
/> />
</div> </div>
<SelectAnalysisPackages <SelectAnalysisPackages

View File

@@ -37,8 +37,8 @@ async function OrderAnalysisPage() {
return ( return (
<> <>
<HomeLayoutPageHeader <HomeLayoutPageHeader
title={<Trans i18nKey="order-analysis:title" />} title={<Trans i18nKey={'order-analysis:title'} />}
description={<Trans i18nKey="order-analysis:description" />} description={<Trans i18nKey={'order-analysis:description'} />}
/> />
<PageBody> <PageBody>
<OrderAnalysesCards analyses={analyses} countryCode={countryCode} /> <OrderAnalysesCards analyses={analyses} countryCode={countryCode} />

View File

@@ -1,19 +1,18 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react';
import CartTotals from '@/app/home/(user)/_components/order/cart-totals'; import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
import OrderDetails from '@/app/home/(user)/_components/order/order-details'; import OrderDetails from '@/app/home/(user)/_components/order/order-details';
import OrderItems from '@/app/home/(user)/_components/order/order-items'; import OrderItems from '@/app/home/(user)/_components/order/order-items';
import { retrieveOrder } from '@lib/data/orders';
import { StoreOrder } from '@medusajs/types';
import Divider from '@modules/common/components/divider'; import Divider from '@modules/common/components/divider';
import { GlobalLoader } from '@kit/ui/makerkit/global-loader';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { AnalysisOrder } from '~/lib/types/order'; 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({ function OrderConfirmedLoadingWrapper({
medusaOrder: initialMedusaOrder, medusaOrder: initialMedusaOrder,
@@ -22,8 +21,7 @@ function OrderConfirmedLoadingWrapper({
medusaOrder: StoreOrder; medusaOrder: StoreOrder;
order: AnalysisOrder; order: AnalysisOrder;
}) { }) {
const [medusaOrder, setMedusaOrder] = const [medusaOrder, setMedusaOrder] = useState<StoreOrder>(initialMedusaOrder);
useState<StoreOrder>(initialMedusaOrder);
const fetchingRef = useRef(false); const fetchingRef = useRef(false);
const paymentStatus = medusaOrder.payment_status; const paymentStatus = medusaOrder.payment_status;
@@ -54,7 +52,7 @@ function OrderConfirmedLoadingWrapper({
if (!isPaid) { if (!isPaid) {
return ( return (
<PageBody> <PageBody>
<div className="flex h-full flex-col items-center justify-start pt-[10vh]"> <div className="flex flex-col justify-start items-center h-full pt-[10vh]">
<div> <div>
<GlobalLoader /> <GlobalLoader />
</div> </div>
@@ -71,14 +69,7 @@ function OrderConfirmedLoadingWrapper({
<PageHeader title={<Trans i18nKey="cart:orderConfirmed.title" />} /> <PageHeader title={<Trans i18nKey="cart:orderConfirmed.title" />} />
<Divider /> <Divider />
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 gap-y-6 lg:px-4"> <div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 gap-y-6 lg:px-4">
<OrderDetails <OrderDetails order={order} />
order={{
id: order.medusa_order_id,
created_at: order.created_at,
location: null,
serviceProvider: null,
}}
/>
<Divider /> <Divider />
<OrderItems medusaOrder={medusaOrder} /> <OrderItems medusaOrder={medusaOrder} />
<CartTotals medusaOrder={medusaOrder} /> <CartTotals medusaOrder={medusaOrder} />

View File

@@ -7,7 +7,6 @@ import { pathsConfig } from '@kit/shared/config';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { getAnalysisOrder } from '~/lib/services/order.service'; import { getAnalysisOrder } from '~/lib/services/order.service';
import OrderConfirmedLoadingWrapper from './order-confirmed-loading-wrapper'; import OrderConfirmedLoadingWrapper from './order-confirmed-loading-wrapper';
export async function generateMetadata() { export async function generateMetadata() {
@@ -37,9 +36,7 @@ async function OrderConfirmedPage(props: {
redirect(pathsConfig.app.myOrders); redirect(pathsConfig.app.myOrders);
} }
return ( return <OrderConfirmedLoadingWrapper medusaOrder={medusaOrder} order={order} />;
<OrderConfirmedLoadingWrapper medusaOrder={medusaOrder} order={order} />
);
} }
export default withI18n(OrderConfirmedPage); export default withI18n(OrderConfirmedPage);

View File

@@ -4,7 +4,6 @@ import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
import OrderDetails from '@/app/home/(user)/_components/order/order-details'; import OrderDetails from '@/app/home/(user)/_components/order/order-details';
import OrderItems from '@/app/home/(user)/_components/order/order-items'; import OrderItems from '@/app/home/(user)/_components/order/order-items';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { retrieveOrder } from '@lib/data/orders'; import { retrieveOrder } from '@lib/data/orders';
import Divider from '@modules/common/components/divider'; import Divider from '@modules/common/components/divider';
@@ -13,6 +12,7 @@ import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { getAnalysisOrder } from '~/lib/services/order.service';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -25,46 +25,11 @@ export async function generateMetadata() {
async function OrderConfirmedPage(props: { async function OrderConfirmedPage(props: {
params: Promise<{ orderId: string }>; params: Promise<{ orderId: string }>;
}) { }) {
const supabaseClient = getSupabaseServerClient();
const params = await props.params; const params = await props.params;
const medusaOrder = await retrieveOrder(params.orderId).catch(() => null); const medusaOrder = await retrieveOrder(params.orderId).catch(() => null);
if (!medusaOrder) { if (!medusaOrder) {
redirect(pathsConfig.app.myOrders); redirect(pathsConfig.app.myOrders);
} }
const ttoReservationId =
medusaOrder.items &&
(medusaOrder.items.find(
({ metadata }) => !!metadata?.connectedOnlineReservationId,
)?.metadata?.connectedOnlineReservationId as number);
const [{ data: ttoReservation }] = await Promise.all([
ttoReservationId
? await supabaseClient
.schema('medreport')
.from('connected_online_reservation')
.select('*')
.eq('id', ttoReservationId)
.single()
: { data: null },
]);
const [{ data: location }, { data: serviceProvider }] = await Promise.all([
ttoReservation
? supabaseClient
.schema('medreport')
.from('connected_online_locations')
.select('name,address')
.eq('sync_id', ttoReservation.location_sync_id || 0)
.single()
: { data: null },
ttoReservation
? supabaseClient
.schema('medreport')
.from('connected_online_providers')
.select('email,phone_number,name')
.eq('id', ttoReservation.clinic_id)
.single()
: { data: null },
]);
return ( return (
<PageBody> <PageBody>
@@ -75,8 +40,6 @@ async function OrderConfirmedPage(props: {
order={{ order={{
id: medusaOrder.id, id: medusaOrder.id,
created_at: medusaOrder.created_at, created_at: medusaOrder.created_at,
location,
serviceProvider,
}} }}
/> />
<Divider /> <Divider />

View File

@@ -29,8 +29,7 @@ export async function generateMetadata() {
} }
async function OrdersPage() { async function OrdersPage() {
const [medusaOrders, analysisOrders, ttoOrders, { productTypes }] = const [medusaOrders, analysisOrders, ttoOrders, { productTypes }] = await Promise.all([
await Promise.all([
listOrders(ORDERS_LIMIT), listOrders(ORDERS_LIMIT),
getAnalysisOrders(), getAnalysisOrders(),
getTtoOrders(), getTtoOrders(),
@@ -58,24 +57,13 @@ async function OrdersPage() {
/> />
<PageBody> <PageBody>
{medusaOrders.map((medusaOrder) => { {medusaOrders.map((medusaOrder) => {
if (!medusaOrder) {
return null;
}
const analysisOrder = analysisOrders.find( const analysisOrder = analysisOrders.find(
({ medusa_order_id }) => medusa_order_id === medusaOrder.id, ({ medusa_order_id }) => medusa_order_id === medusaOrder.id,
); );
const orderIds = new Set( if (!medusaOrder) {
(medusaOrder?.items ?? []) return null;
.map((i) => i?.metadata?.connectedOnlineReservationId) }
.filter(Boolean)
.map(String),
);
const ttoReservation = ttoOrders.find((o) =>
orderIds.has(String(o.id)),
);
const medusaOrderItems = medusaOrder.items || []; const medusaOrderItems = medusaOrder.items || [];
const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter( const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter(
@@ -92,21 +80,13 @@ async function OrdersPage() {
), ),
); );
if (
medusaOrderItemsAnalysisPackages.length === 0 &&
medusaOrderItemsOther.length === 0 &&
medusaOrderItemsTtoServices.length === 0
) {
return null;
}
return ( return (
<React.Fragment key={medusaOrder.id}> <React.Fragment key={medusaOrder.id}>
<Divider className="my-6" /> <Divider className="my-6" />
<OrderBlock <OrderBlock
medusaOrderId={medusaOrder.id} medusaOrderId={medusaOrder.id}
analysisOrder={analysisOrder} analysisOrder={analysisOrder}
ttoReservation={ttoReservation} medusaOrderStatus={medusaOrder.status}
itemsAnalysisPackage={medusaOrderItemsAnalysisPackages} itemsAnalysisPackage={medusaOrderItemsAnalysisPackages}
itemsTtoService={medusaOrderItemsTtoServices} itemsTtoService={medusaOrderItemsTtoServices}
itemsOther={medusaOrderItemsOther} itemsOther={medusaOrderItemsOther}

View File

@@ -1,3 +1,5 @@
import { Suspense } from 'react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { toTitleCase } from '@/lib/utils'; import { toTitleCase } from '@/lib/utils';
@@ -10,9 +12,11 @@ import { createUserAnalysesApi } from '@kit/user-analyses/api';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import AIBlocks from '../_components/ai/ai-blocks';
import Dashboard from '../_components/dashboard'; import Dashboard from '../_components/dashboard';
import DashboardCards from '../_components/dashboard-cards'; 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'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
export const generateMetadata = async () => { export const generateMetadata = async () => {
@@ -29,10 +33,7 @@ async function UserHomePage() {
const { account } = await loadCurrentUserAccount(); const { account } = await loadCurrentUserAccount();
const api = createUserAnalysesApi(client); const api = createUserAnalysesApi(client);
const userAnalysesApi = createUserAnalysesApi(client);
const bmiThresholds = await api.fetchBmiThresholds(); const bmiThresholds = await api.fetchBmiThresholds();
const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses();
if (!account) { if (!account) {
redirect('/'); redirect('/');
@@ -52,13 +53,16 @@ async function UserHomePage() {
/> />
<PageBody> <PageBody>
<Dashboard account={account} bmiThresholds={bmiThresholds} /> <Dashboard account={account} bmiThresholds={bmiThresholds} />
{(await isValidOpenAiEnv()) && (
<>
<h4> <h4>
<Trans i18nKey="dashboard:recommendations.title" /> <Trans i18nKey="dashboard:recommendations.title" />
</h4> </h4>
<div className="mt-4 grid gap-6 sm:grid-cols-3"> <Suspense fallback={<RecommendationsSkeleton />}>
<AIBlocks account={account} analysisResponses={analysisResponses} /> <Recommendations account={account} />
</div> </Suspense>
</>
)}
</PageBody> </PageBody>
</> </>
); );

View File

@@ -1,52 +0,0 @@
'use server';
import React, { Suspense } from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { isValidOpenAiEnv } from '../../_lib/server/is-valid-open-ai-env';
import LifeStyleCard from './life-style-card';
import OrderAnalysesPackageCard from './order-analyses-package-card';
import Recommendations from './recommendations';
import RecommendationsSkeleton from './recommendations-skeleton';
import { AnalysisResponses } from './types';
const AIBlocks = async ({
account,
analysisResponses,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
}) => {
const isOpenAiAvailable = await isValidOpenAiEnv();
if (!isOpenAiAvailable) {
return <OrderAnalysesPackageCard />;
}
if (analysisResponses?.length === 0) {
return (
<>
<OrderAnalysesPackageCard />
<Suspense fallback={<RecommendationsSkeleton amount={1} />}>
<LifeStyleCard
account={account}
analysisResponses={analysisResponses}
/>
</Suspense>
</>
);
}
return (
<Suspense fallback={<RecommendationsSkeleton />}>
<LifeStyleCard account={account} analysisResponses={analysisResponses} />
<Recommendations
account={account}
analysisResponses={analysisResponses}
/>
</Suspense>
);
};
export default AIBlocks;

View File

@@ -1,56 +0,0 @@
'use server';
import React from 'react';
import Link from 'next/link';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { pathsConfig } from '@/packages/shared/src/config';
import { ChevronRight } from 'lucide-react';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import { Card, CardHeader } from '@kit/ui/shadcn/card';
import { getLatestResponseTime } from '~/lib/utils';
import { loadLifeStyle } from '../../_lib/server/load-life-style';
import { AnalysisResponses } from './types';
const LifeStyleCard = async ({
account,
analysisResponses,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
}) => {
const aiResponseTimestamp = getLatestResponseTime(analysisResponses);
const { response, dateCreated } = await loadLifeStyle({
account,
analysisResponses,
aiResponseTimestamp,
});
return (
<Card variant="gradient-success" className="flex flex-col justify-between">
<CardHeader className="flex-row justify-between">
<div>
<span className="text-xs">
{new Date(dateCreated).toLocaleString()}
</span>
<h5 className="flex flex-col">
<Trans i18nKey="dashboard:heroCard.lifeStyle.title" />
</h5>
</div>
<Link href={pathsConfig.app.lifeStyle}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
</CardHeader>
<span className="text-primary p-4 text-sm">{response.summary}</span>
</Card>
);
};
export default LifeStyleCard;

View File

@@ -1,51 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { pathsConfig } from '@/packages/shared/src/config';
import { ChevronRight, HeartPulse } from 'lucide-react';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import {
Card,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/shadcn/card';
const OrderAnalysesPackageCard = () => {
return (
<Card
variant="gradient-success"
className="xs:w-1/2 flex w-full flex-col justify-between sm:w-auto"
>
<CardHeader className="flex-row sm:pb-0">
<div
className={
'bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white'
}
>
<HeartPulse className="size-4 fill-green-500" />
</div>
<div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
<Link href={pathsConfig.app.orderAnalysisPackage}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
</div>
</CardHeader>
<CardFooter className="flex flex-col items-start gap-2">
<h5>
<Trans i18nKey="dashboard:heroCard.orderPackage.title" />
</h5>
<CardDescription className="text-primary">
<Trans i18nKey="dashboard:heroCard.orderPackage.description" />
</CardDescription>
</CardFooter>
</Card>
);
};
export default OrderAnalysesPackageCard;

View File

@@ -1,67 +0,0 @@
import React from 'react';
import { InfoTooltip } from '@/packages/shared/src/components/ui/info-tooltip';
import { Button } from '@kit/ui/shadcn/button';
import {
Card,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/shadcn/card';
import { Skeleton } from '@kit/ui/skeleton';
const RecommendationsSkeleton = ({ amount = 2 }: { amount?: number }) => {
const emptyData = [
{
title: '1',
description: '',
subtitle: '',
variant: { id: '' },
price: 1,
},
];
return Array.from({ length: amount }, (_, index) => {
const { title, description, subtitle } = emptyData[0]!;
return (
<Skeleton key={title + index}>
<Card>
<CardHeader className="flex-row">
<div
className={
'mb-6 flex size-8 items-center-safe justify-center-safe'
}
/>
<div className="ml-auto flex size-8 items-center-safe justify-center-safe">
<Button size="icon" className="px-2" />
</div>
</CardHeader>
<CardFooter className="flex">
<div className="flex flex-1 flex-col items-start">
<h5>
{title}
{description && (
<>
{' '}
<InfoTooltip
content={
<div className="flex flex-col gap-2">
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
</div>
<div className="flex flex-col items-end gap-2 self-end text-sm"></div>
</CardFooter>
</Card>
</Skeleton>
);
});
};
export default RecommendationsSkeleton;

View File

@@ -1,41 +0,0 @@
'use server';
import React from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { getLatestResponseTime } from '~/lib/utils';
import { loadAnalyses } from '../../_lib/server/load-analyses';
import { loadRecommendations } from '../../_lib/server/load-recommendations';
import OrderAnalysesCards from '../order-analyses-cards';
import { AnalysisResponses } from './types';
export default async function Recommendations({
account,
analysisResponses,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
}) {
const { analyses, countryCode } = await loadAnalyses();
const currentAIResponseTimestamp = getLatestResponseTime(analysisResponses);
const analysisRecommendations = await loadRecommendations({
account,
analyses,
analysisResponses,
aiResponseTimestamp: currentAIResponseTimestamp,
});
const orderAnalyses = analyses.filter((analysis) =>
analysisRecommendations.includes(analysis.title),
);
if (orderAnalyses.length === 0) {
return null;
}
return (
<OrderAnalysesCards analyses={orderAnalyses} countryCode={countryCode} />
);
}

View File

@@ -1,19 +0,0 @@
import { Database } from '@/packages/supabase/src/database.types';
export interface ILifeStyleResponse {
lifestyle: {
title: string;
description: string;
score: 0 | 1 | 2;
}[];
summary: string | null;
}
export enum PROMPT_NAME {
LIFE_STYLE = 'Life Style',
ANALYSIS_RECOMMENDATIONS = 'Analysis Recommendations',
FEEDBACK = 'Doctor Feedback',
}
export type AnalysisResponses =
Database['medreport']['Functions']['get_latest_analysis_response_elements_for_current_user']['Returns'];

View File

@@ -32,16 +32,7 @@ const BookingContainer = ({
<BookingProvider category={{ products }} service={cartItem?.product}> <BookingProvider category={{ products }} service={cartItem?.product}>
<div className="xs:flex-row flex max-h-full flex-col gap-6"> <div className="xs:flex-row flex max-h-full flex-col gap-6">
<div className="flex flex-col"> <div className="flex flex-col">
<ServiceSelector <ServiceSelector products={products} />
products={products.filter((product) => {
if (product.metadata?.serviceIds) {
return Array.isArray(
JSON.parse(product.metadata.serviceIds as string),
);
}
return false;
})}
/>
<BookingCalendar /> <BookingCalendar />
<LocationSelector /> <LocationSelector />
</div> </div>

View File

@@ -1,113 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/makerkit/trans';
import { cn } from '@kit/ui/shadcn';
import { Button } from '@kit/ui/shadcn/button';
const BookingPagination = ({
totalPages,
setCurrentPage,
currentPage,
}: {
totalPages: number;
setCurrentPage: (page: number) => void;
currentPage: number;
}) => {
const { t } = useTranslation();
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 (totalPages === 0) {
return (
<div className="wrap text-muted-foreground flex size-full content-center-safe justify-center-safe">
<p>{t('booking:noResults')}</p>
</div>
);
}
return (
totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{t('common:pageOfPages', {
page: currentPage,
total: totalPages,
})}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<Trans i18nKey="common:previous" defaultValue="Previous" />
</Button>
{generatePageNumbers().map((page, index) => (
<Button
key={index}
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() => typeof page === 'number' && setCurrentPage(page)}
disabled={page === '...'}
className={cn(
'min-w-[2rem]',
page === '...' && 'cursor-default hover:bg-transparent',
)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
<Trans i18nKey="common:next" defaultValue="Next" />
</Button>
</div>
</div>
)
);
};
export default BookingPagination;

View File

@@ -45,13 +45,14 @@ export const BookingProvider: React.FC<{
const updateTimeSlots = async (serviceIds: number[]) => { const updateTimeSlots = async (serviceIds: number[]) => {
setIsLoadingTimeSlots(true); setIsLoadingTimeSlots(true);
try { try {
console.log('serviceIds', serviceIds, selectedLocationId);
const response = await getAvailableTimeSlotsForDisplay( const response = await getAvailableTimeSlotsForDisplay(
serviceIds, serviceIds,
selectedLocationId, selectedLocationId,
); );
setTimeSlots(response.timeSlots); setTimeSlots(response.timeSlots);
setLocations(response.locations); setLocations(response.locations);
} catch { } catch (error) {
setTimeSlots(null); setTimeSlots(null);
} finally { } finally {
setIsLoadingTimeSlots(false); setIsLoadingTimeSlots(false);

View File

@@ -1,4 +1,5 @@
import { Label } from '@medusajs/ui'; import { Label } from '@medusajs/ui';
import { useTranslation } from 'react-i18next';
import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group';
import { Card } from '@kit/ui/shadcn/card'; import { Card } from '@kit/ui/shadcn/card';
@@ -7,6 +8,7 @@ import { Trans } from '@kit/ui/trans';
import { useBooking } from './booking.provider'; import { useBooking } from './booking.provider';
const LocationSelector = () => { const LocationSelector = () => {
const { t } = useTranslation();
const { selectedLocationId, setSelectedLocationId, locations } = useBooking(); const { selectedLocationId, setSelectedLocationId, locations } = useBooking();
const onLocationSelect = (locationId: number | string | null) => { const onLocationSelect = (locationId: number | string | null) => {
@@ -14,15 +16,6 @@ const LocationSelector = () => {
setSelectedLocationId(Number(locationId)); setSelectedLocationId(Number(locationId));
}; };
const uniqueLocations = locations?.filter((item, index, self) => {
return (
index ===
self.findIndex(
(loc) => loc.sync_id === item.sync_id && loc.name === item.name,
)
);
});
return ( return (
<Card className="mb-4 p-4"> <Card className="mb-4 p-4">
<h5 className="text-semibold mb-2"> <h5 className="text-semibold mb-2">
@@ -33,23 +26,20 @@ const LocationSelector = () => {
className="mb-2 flex flex-col" className="mb-2 flex flex-col"
onValueChange={(val) => onLocationSelect(val)} onValueChange={(val) => onLocationSelect(val)}
> >
{/* <div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem <RadioGroupItem
value="all" value={'all'}
id="all" id={'all'}
checked={selectedLocationId === null} checked={selectedLocationId === null}
/> />
<Label htmlFor="all">{t('booking:showAllLocations')}</Label> <Label htmlFor={'all'}>{t('booking:showAllLocations')}</Label>
</div> */} </div>
{uniqueLocations?.map((location, index) => ( {locations?.map((location) => (
<div key={location.sync_id} className="flex items-center gap-2"> <div key={location.sync_id} className="flex items-center gap-2">
<RadioGroupItem <RadioGroupItem
value={location.sync_id.toString()} value={location.sync_id.toString()}
id={location.sync_id.toString()} id={location.sync_id.toString()}
checked={ checked={selectedLocationId === location.sync_id}
selectedLocationId === location.sync_id ||
(index === 0 && selectedLocationId === null)
}
/> />
<Label htmlFor={location.sync_id.toString()}> <Label htmlFor={location.sync_id.toString()}>
{location.name} {location.name}

View File

@@ -11,7 +11,6 @@ import { pathsConfig } from '@kit/shared/config';
import { formatDateAndTime } from '@kit/shared/utils'; import { formatDateAndTime } from '@kit/shared/utils';
import { Button } from '@kit/ui/shadcn/button'; import { Button } from '@kit/ui/shadcn/button';
import { Card } from '@kit/ui/shadcn/card'; import { Card } from '@kit/ui/shadcn/card';
import { Skeleton } from '@kit/ui/shadcn/skeleton';
import { toast } from '@kit/ui/sonner'; import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
@@ -20,7 +19,6 @@ import { updateReservationTime } from '~/lib/services/reservation.service';
import { createInitialReservationAction } from '../../_lib/server/actions'; import { createInitialReservationAction } from '../../_lib/server/actions';
import { EnrichedCartItem } from '../cart/types'; import { EnrichedCartItem } from '../cart/types';
import BookingPagination from './booking-pagination';
import { ServiceProvider, TimeSlot } from './booking.context'; import { ServiceProvider, TimeSlot } from './booking.context';
import { useBooking } from './booking.provider'; import { useBooking } from './booking.provider';
@@ -70,16 +68,57 @@ const TimeSlots = ({
}) ?? [], }) ?? [],
'StartTime', 'StartTime',
'asc', 'asc',
).filter(({ StartTime }) => isSameDay(StartTime, selectedDate)), ),
[booking.timeSlots, selectedDate], [booking.timeSlots, selectedDate],
); );
const totalPages = Math.ceil(filteredBookings.length / PAGE_SIZE);
const paginatedBookings = useMemo(() => { const paginatedBookings = useMemo(() => {
const startIndex = (currentPage - 1) * PAGE_SIZE; const startIndex = (currentPage - 1) * PAGE_SIZE;
const endIndex = startIndex + PAGE_SIZE; const endIndex = startIndex + PAGE_SIZE;
return filteredBookings.slice(startIndex, endIndex); return filteredBookings.slice(startIndex, endIndex);
}, [filteredBookings, currentPage, PAGE_SIZE]); }, [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) { if (!booking?.timeSlots?.length) {
return null; return null;
} }
@@ -104,16 +143,11 @@ const TimeSlots = ({
timeSlot.StartTime, timeSlot.StartTime,
booking.selectedLocationId ? booking.selectedLocationId : null, booking.selectedLocationId ? booking.selectedLocationId : null,
comments, comments,
) ).then(() => {
.then(() => {
if (onComplete) { if (onComplete) {
onComplete(); onComplete();
} }
router.push(pathsConfig.app.cart); router.push(pathsConfig.app.cart);
})
.catch((error) => {
console.error('Booking error: ', error);
throw error;
}); });
toast.promise(() => bookTimePromise, { toast.promise(() => bookTimePromise, {
@@ -169,13 +203,10 @@ const TimeSlots = ({
}; };
return ( return (
<Skeleton <div className="flex w-full flex-col gap-4">
isLoading={booking.isLoadingTimeSlots}
className="flex w-full flex-col gap-4"
>
<div className="flex h-full w-full flex-col gap-2 overflow-auto"> <div className="flex h-full w-full flex-col gap-2 overflow-auto">
{paginatedBookings.map((timeSlot, index) => { {paginatedBookings.map((timeSlot, index) => {
const isHaigeKassa = timeSlot.HKServiceID > 0; const isEHIF = timeSlot.HKServiceID > 0;
const serviceProviderTitle = getServiceProviderTitle( const serviceProviderTitle = getServiceProviderTitle(
currentLocale, currentLocale,
timeSlot.serviceProvider, timeSlot.serviceProvider,
@@ -183,10 +214,9 @@ const TimeSlots = ({
const price = const price =
booking.selectedService?.variants?.[0]?.calculated_price booking.selectedService?.variants?.[0]?.calculated_price
?.calculated_amount ?? cartItem?.unit_price; ?.calculated_amount ?? cartItem?.unit_price;
return ( return (
<Card <Card
className="xs:flex xs:justify-between w-full justify-center-safe gap-3 p-4" className="xs:flex xs:justify-between grid w-full justify-center-safe gap-3 p-4"
key={index} key={index}
> >
<div> <div>
@@ -194,7 +224,7 @@ const TimeSlots = ({
<div className="flex"> <div className="flex">
<h5 <h5
className={cn( className={cn(
(serviceProviderTitle || isHaigeKassa) && (serviceProviderTitle || isEHIF) &&
"after:mx-2 after:content-['·']", "after:mx-2 after:content-['·']",
)} )}
> >
@@ -202,14 +232,12 @@ const TimeSlots = ({
</h5> </h5>
{serviceProviderTitle && ( {serviceProviderTitle && (
<span <span
className={cn( className={cn(isEHIF && "after:mx-2 after:content-['·']")}
isHaigeKassa && "after:mx-2 after:content-['·']",
)}
> >
{serviceProviderTitle} {serviceProviderTitle}
</span> </span>
)} )}
{isHaigeKassa && <span>{t('booking:ehifBooking')}</span>} {isEHIF && <span>{t('booking:ehifBooking')}</span>}
</div> </div>
<div className="flex text-xs">{timeSlot.location?.address}</div> <div className="flex text-xs">{timeSlot.location?.address}</div>
</div> </div>
@@ -228,14 +256,63 @@ const TimeSlots = ({
</Card> </Card>
); );
})} })}
{!paginatedBookings.length && (
<div className="wrap text-muted-foreground flex size-full content-center-safe justify-center-safe">
<p>{t('booking:noResults')}</p>
</div>
)}
</div> </div>
<BookingPagination {totalPages > 1 && (
totalPages={Math.ceil(filteredBookings.length / PAGE_SIZE)} <div className="flex items-center justify-between">
setCurrentPage={setCurrentPage} <div className="text-muted-foreground text-sm">
currentPage={currentPage} {t('common:pageOfPages', {
/> page: currentPage,
</Skeleton> total: totalPages,
})}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<Trans i18nKey="common:previous" defaultValue="Previous" />
</Button>
{generatePageNumbers().map((page, index) => (
<Button
key={index}
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() =>
typeof page === 'number' && handlePageChange(page)
}
disabled={page === '...'}
className={cn(
'min-w-[2rem]',
page === '...' && 'cursor-default hover:bg-transparent',
)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<Trans i18nKey="common:next" defaultValue="Next" />
</Button>
</div>
</div>
)}
</div>
); );
}; };

View File

@@ -1,10 +1,13 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { StoreCart, StoreCartLineItem } from '@medusajs/types'; import { StoreCart, StoreCartLineItem } from '@medusajs/types';
import { useFormContext } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod';
import { Form } from '@kit/ui/form';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,6 +22,10 @@ import { Trans } from '@kit/ui/trans';
import { updateCartPartnerLocation } from '../../_lib/server/update-cart-partner-location'; import { updateCartPartnerLocation } from '../../_lib/server/update-cart-partner-location';
import partnerLocations from './partner-locations.json'; import partnerLocations from './partner-locations.json';
const AnalysisLocationSchema = z.object({
locationId: z.string().min(1),
});
export default function AnalysisLocation({ export default function AnalysisLocation({
cart, cart,
synlabAnalyses, synlabAnalyses,
@@ -28,15 +35,21 @@ export default function AnalysisLocation({
}) { }) {
const { t } = useTranslation('cart'); const { t } = useTranslation('cart');
const { watch, setValue } = useFormContext(); const form = useForm<z.infer<typeof AnalysisLocationSchema>>({
const currentValue = watch('locationId'); defaultValues: {
locationId: (cart.metadata?.partner_location_id as string) ?? '',
},
resolver: zodResolver(AnalysisLocationSchema),
});
const getLocation = (locationId: string) => const getLocation = (locationId: string) =>
partnerLocations.find(({ name }) => name === locationId); partnerLocations.find(({ name }) => name === locationId);
const selectedLocation = getLocation(currentValue); const selectedLocation = getLocation(form.watch('locationId'));
const handleUpdateCartPartnerLocation = async (locationId: string) => { const onSubmit = async ({
locationId,
}: z.infer<typeof AnalysisLocationSchema>) => {
const promise = updateCartPartnerLocation({ const promise = updateCartPartnerLocation({
cartId: cart.id, cartId: cart.id,
lineIds: synlabAnalyses.map(({ id }) => id), lineIds: synlabAnalyses.map(({ id }) => id),
@@ -57,17 +70,21 @@ export default function AnalysisLocation({
<Trans i18nKey={'cart:locations.description'} /> <Trans i18nKey={'cart:locations.description'} />
</p> </p>
<div className="mb-2 flex w-full flex-1 gap-x-2"> <Form {...form}>
<form
onSubmit={form.handleSubmit((data) => onSubmit(data))}
className="mb-2 flex w-full flex-1 gap-x-2"
>
<Select <Select
value={currentValue} value={form.watch('locationId')}
onValueChange={(value) => { onValueChange={(value) => {
setValue('locationId', value, { form.setValue('locationId', value, {
shouldValidate: true, shouldValidate: true,
shouldDirty: true, shouldDirty: true,
shouldTouch: true, shouldTouch: true,
}); });
return handleUpdateCartPartnerLocation(value); return onSubmit(form.getValues());
}} }}
> >
<SelectTrigger> <SelectTrigger>
@@ -98,7 +115,8 @@ export default function AnalysisLocation({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </form>
</Form>
{selectedLocation && ( {selectedLocation && (
<div className="mb-4 flex flex-col gap-y-2"> <div className="mb-4 flex flex-col gap-y-2">

View File

@@ -1,219 +0,0 @@
'use client';
import { formatCurrency } from '@/packages/shared/src/utils';
import {
StoreCart,
StoreCartLineItem,
StoreCartPromotion,
} from '@medusajs/types';
import { Loader2 } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader } from '@kit/ui/card';
import { Checkbox } from '@kit/ui/checkbox';
import { FormControl, FormField, FormItem, FormLabel } from '@kit/ui/form';
import { Trans } from '@kit/ui/trans';
import { cn } from '~/lib/utils';
import AnalysisLocation from './analysis-location';
import CartItems from './cart-items';
import CartServiceItems from './cart-service-items';
import DiscountCode from './discount-code';
import { EnrichedCartItem, GetBalanceSummarySelection } from './types';
const IS_DISCOUNT_SHOWN = true as boolean;
export default function CartFormContent({
cart,
synlabAnalyses,
ttoServiceItems,
unavailableLineItemIds,
isInitiatingSession,
getBalanceSummarySelection,
}: {
cart: StoreCart & {
promotions: StoreCartPromotion[];
};
synlabAnalyses: StoreCartLineItem[];
ttoServiceItems: EnrichedCartItem[];
unavailableLineItemIds?: string[];
isInitiatingSession: boolean;
getBalanceSummarySelection: GetBalanceSummarySelection;
}) {
const {
i18n: { language },
} = useTranslation();
const { watch } = useFormContext();
const items = cart?.items ?? [];
const hasCartItems = cart && Array.isArray(items) && items.length > 0;
const isLocationsShown = synlabAnalyses.length > 0;
const useCompanyBenefits = watch('useCompanyBenefits');
const balanceSummary = getBalanceSummarySelection({ useCompanyBenefits });
const { benefitsAmount, benefitsAmountTotal, montonioAmount } =
balanceSummary;
const hasBenefitsApplied = benefitsAmountTotal > 0 && !!useCompanyBenefits;
return (
<>
<div className="flex flex-col gap-y-6 bg-white">
<CartItems
cart={cart}
items={synlabAnalyses}
productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel"
/>
<CartServiceItems
cart={cart}
items={ttoServiceItems}
productColumnLabelKey="cart:items.ttoServices.productColumnLabel"
unavailableLineItemIds={unavailableLineItemIds}
/>
</div>
{hasCartItems && (
<>
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.subtotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
<div
className={cn(
'flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4',
{
'py-2 sm:py-4': !hasBenefitsApplied,
},
)}
>
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.promotionsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
{hasBenefitsApplied && (
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.companyBenefitsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: benefitsAmount,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
)}
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.total" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: montonioAmount,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
{benefitsAmountTotal > 0 && (
<FormField
name="useCompanyBenefits"
render={({ field }) => (
<FormItem className="mt-8">
<div className="flex flex-row items-center gap-2 pb-1">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>
<Trans i18nKey={'cart:companyBenefits.label'} />
</FormLabel>
</div>
</FormItem>
)}
/>
)}
</>
)}
<div className="flex flex-col gap-x-4 gap-y-6 py-4 sm:flex-row sm:py-8">
{IS_DISCOUNT_SHOWN && (
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:discountCode.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<DiscountCode cart={{ ...cart }} />
</CardContent>
</Card>
)}
{isLocationsShown && (
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:locations.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<AnalysisLocation
cart={{ ...cart }}
synlabAnalyses={synlabAnalyses}
/>
</CardContent>
</Card>
)}
</div>
<div>
<Button className="h-10" type="submit" disabled={isInitiatingSession}>
{isInitiatingSession && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>
</>
);
}

View File

@@ -1,49 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { StoreCart } from '@medusajs/types';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@kit/ui/form';
const CartFormSchema = z.object({
code: z.string().optional(),
locationId: z.string().optional(),
useCompanyBenefits: z.boolean(),
});
export type CartFormOnSubmit = (
data: z.infer<typeof CartFormSchema>,
) => Promise<void>;
export default function CartForm({
children,
cart,
onSubmit,
}: {
children: React.ReactNode;
cart: StoreCart;
onSubmit: CartFormOnSubmit;
}) {
const form = useForm<z.infer<typeof CartFormSchema>>({
defaultValues: {
code: '',
locationId: (cart.metadata?.partner_location_id as string) ?? '',
useCompanyBenefits: true,
},
resolver: zodResolver(CartFormSchema),
});
const handleSubmit: CartFormOnSubmit = async (data) => {
await onSubmit(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => handleSubmit(data))}>
{children}
</form>
</Form>
);
}

View File

@@ -38,7 +38,6 @@ const CartItemDelete = ({
<button <button
className="text-ui-fg-subtle hover:text-ui-fg-base flex cursor-pointer gap-x-1" className="text-ui-fg-subtle hover:text-ui-fg-base flex cursor-pointer gap-x-1"
onClick={() => handleDelete()} onClick={() => handleDelete()}
type="button"
> >
{isDeleting ? <Spinner className="animate-spin" /> : <Trash />} {isDeleting ? <Spinner className="animate-spin" /> : <Trash />}
<span>{children}</span> <span>{children}</span>

View File

@@ -20,7 +20,7 @@ export default function CartItem({
} = useTranslation(); } = useTranslation();
return ( return (
<TableRow className="sm:w-full" data-testid="product-row"> <TableRow className="w-full" data-testid="product-row">
<TableCell className="w-[100%] px-4 text-left sm:px-6"> <TableCell className="w-[100%] px-4 text-left sm:px-6">
<p <p
className="txt-medium-plus text-ui-fg-base" className="txt-medium-plus text-ui-fg-base"
@@ -41,8 +41,7 @@ export default function CartItem({
</TableCell> </TableCell>
<TableCell className="min-w-[80px] px-4 text-right sm:px-6"> <TableCell className="min-w-[80px] px-4 text-right sm:px-6">
{item.total && {formatCurrency({
formatCurrency({
value: item.total, value: item.total,
currencyCode, currencyCode,
locale: language, locale: language,

View File

@@ -10,7 +10,6 @@ import {
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import CartItem from './cart-item'; import CartItem from './cart-item';
import MobileCartItems from './mobile-cart-items';
export default function CartItems({ export default function CartItems({
cart, cart,
@@ -26,8 +25,7 @@ export default function CartItems({
} }
return ( return (
<> <Table className="border-separate rounded-lg border">
<Table className="hidden border-separate rounded-lg border sm:block">
<TableHeader className="text-ui-fg-subtle txt-medium-plus"> <TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow> <TableRow>
<TableHead className="px-4 sm:px-6"> <TableHead className="px-4 sm:px-6">
@@ -59,21 +57,5 @@ export default function CartItems({
))} ))}
</TableBody> </TableBody>
</Table> </Table>
<div className="sm:hidden">
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<MobileCartItems
key={item.id}
item={item}
currencyCode={cart.currency_code}
productColumnLabelKey={productColumnLabelKey}
/>
))}
</div>
</>
); );
} }

View File

@@ -1,26 +1,76 @@
'use client'; 'use client';
import { useState } from 'react';
import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils'; import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { TableCell, TableRow } from '@kit/ui/table'; import { TableCell, TableRow } from '@kit/ui/table';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import BookingContainer from '../booking/booking-container';
import CartItemDelete from './cart-item-delete'; import CartItemDelete from './cart-item-delete';
import { EnrichedCartItem } from './types'; import { EnrichedCartItem } from './types';
const EditCartServiceItemModal = ({
item,
onComplete,
}: {
item: EnrichedCartItem | null;
onComplete: () => void;
}) => {
if (!item) return null;
return (
<Dialog defaultOpen>
<DialogContent className="xs:max-w-[90vw] flex max-h-screen max-w-full flex-col items-center gap-4 space-y-4 overflow-y-scroll">
<DialogHeader className="items-center text-center">
<DialogTitle>
<Trans i18nKey="cart:editServiceItem.title" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="cart:editServiceItem.description" />
</DialogDescription>
</DialogHeader>
<div>
{item.product && item.reservation.countryCode ? (
<BookingContainer
category={{
products: [item.product],
countryCode: item.reservation.countryCode,
}}
cartItem={item}
onComplete={onComplete}
/>
) : (
<p>
<Trans i18nKey="booking:noProducts" />
</p>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default function CartServiceItem({ export default function CartServiceItem({
item, item,
currencyCode, currencyCode,
isUnavailable, isUnavailable,
setEditingItem,
}: { }: {
item: EnrichedCartItem; item: EnrichedCartItem;
currencyCode: string; currencyCode: string;
isUnavailable?: boolean; isUnavailable?: boolean;
setEditingItem: (item: EnrichedCartItem | null) => void;
}) { }) {
const [editingItem, setEditingItem] = useState<EnrichedCartItem | null>(null);
const { const {
i18n: { language }, i18n: { language },
} = useTranslation(); } = useTranslation();
@@ -56,8 +106,7 @@ export default function CartServiceItem({
</TableCell> </TableCell>
<TableCell className="min-w-[80px] px-4 text-right sm:px-6"> <TableCell className="min-w-[80px] px-4 text-right sm:px-6">
{item.total && {formatCurrency({
formatCurrency({
value: item.total, value: item.total,
currencyCode, currencyCode,
locale: language, locale: language,
@@ -66,11 +115,7 @@ export default function CartServiceItem({
<TableCell className="px-4 text-right sm:px-6"> <TableCell className="px-4 text-right sm:px-6">
<span className="flex justify-end gap-x-1"> <span className="flex justify-end gap-x-1">
<Button <Button size="sm" onClick={() => setEditingItem(item)}>
type="button"
size="sm"
onClick={() => setEditingItem(item)}
>
<Trans i18nKey="common:change" /> <Trans i18nKey="common:change" />
</Button> </Button>
</span> </span>
@@ -92,6 +137,10 @@ export default function CartServiceItem({
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
<EditCartServiceItemModal
item={editingItem}
onComplete={() => setEditingItem(null)}
/>
</> </>
); );
} }

View File

@@ -1,14 +1,5 @@
import { useState } from 'react';
import { StoreCart } from '@medusajs/types'; import { StoreCart } from '@medusajs/types';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@kit/ui/shadcn/dialog';
import { import {
Table, Table,
TableBody, TableBody,
@@ -18,52 +9,9 @@ import {
} from '@kit/ui/table'; } from '@kit/ui/table';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import BookingContainer from '../booking/booking-container';
import CartServiceItem from './cart-service-item'; import CartServiceItem from './cart-service-item';
import MobileCartServiceItems from './mobile-cart-service-items';
import { EnrichedCartItem } from './types'; import { EnrichedCartItem } from './types';
const EditCartServiceItemModal = ({
item,
onComplete,
}: {
item: EnrichedCartItem | null;
onComplete: () => void;
}) => {
if (!item) return null;
return (
<Dialog defaultOpen>
<DialogContent className="xs:max-w-[90vw] flex max-h-screen max-w-full flex-col items-center gap-4 space-y-4 overflow-y-scroll">
<DialogHeader className="items-center text-center">
<DialogTitle>
<Trans i18nKey="cart:editServiceItem.title" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="cart:editServiceItem.description" />
</DialogDescription>
</DialogHeader>
<div>
{item.product && item.reservation.countryCode ? (
<BookingContainer
category={{
products: [item.product],
countryCode: item.reservation.countryCode,
}}
cartItem={item}
onComplete={onComplete}
/>
) : (
<p>
<Trans i18nKey="booking:noProducts" />
</p>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default function CartServiceItems({ export default function CartServiceItems({
cart, cart,
items, items,
@@ -75,14 +23,12 @@ export default function CartServiceItems({
productColumnLabelKey: string; productColumnLabelKey: string;
unavailableLineItemIds?: string[]; unavailableLineItemIds?: string[];
}) { }) {
const [editingItem, setEditingItem] = useState<EnrichedCartItem | null>(null);
if (!items || items.length === 0) { if (!items || items.length === 0) {
return null; return null;
} }
return ( return (
<> <Table className="border-separate rounded-lg border">
<Table className="hidden border-separate rounded-lg border sm:block">
<TableHeader className="text-ui-fg-subtle txt-medium-plus"> <TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow> <TableRow>
<TableHead className="px-4 sm:px-6"> <TableHead className="px-4 sm:px-6">
@@ -118,32 +64,9 @@ export default function CartServiceItems({
item={item} item={item}
currencyCode={cart.currency_code} currencyCode={cart.currency_code}
isUnavailable={unavailableLineItemIds?.includes(item.id)} isUnavailable={unavailableLineItemIds?.includes(item.id)}
setEditingItem={setEditingItem}
/> />
))} ))}
</TableBody> </TableBody>
</Table> </Table>
<div className="sm:hidden">
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<MobileCartServiceItems
key={item.id}
item={item}
currencyCode={cart.currency_code}
isUnavailable={unavailableLineItemIds?.includes(item.id)}
productColumnLabelKey={productColumnLabelKey}
setEditingItem={setEditingItem}
/>
))}
</div>
<EditCartServiceItemModal
item={editingItem}
onComplete={() => setEditingItem(null)}
/>
</>
); );
} }

View File

@@ -1,16 +1,18 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { convertToLocale } from '@lib/util/money'; import { convertToLocale } from '@lib/util/money';
import { StoreCart, StoreCartPromotion } from '@medusajs/types'; import { StoreCart, StorePromotion } from '@medusajs/types';
import { Badge, Text } from '@medusajs/ui'; import { Badge, Text } from '@medusajs/ui';
import Trash from '@modules/common/icons/trash'; import Trash from '@modules/common/icons/trash';
import { useFormContext } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { FormControl, FormField, FormItem } from '@kit/ui/form'; import { Form, FormControl, FormField, FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner'; import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
@@ -20,22 +22,21 @@ import {
removePromotionCodeAction, removePromotionCodeAction,
} from './discount-code-actions'; } from './discount-code-actions';
const DiscountCodeSchema = z.object({
code: z.string().min(1),
});
export default function DiscountCode({ export default function DiscountCode({
cart, cart,
}: { }: {
cart: StoreCart & { cart: StoreCart & {
promotions: StoreCartPromotion[]; promotions: StorePromotion[];
}; };
}) { }) {
const { t } = useTranslation('cart'); const { t } = useTranslation('cart');
const { setValue, watch } = useFormContext();
const currentValue = watch('code');
const { promotions = [] } = cart; const { promotions = [] } = cart;
const [isAddingPromotionCode, setIsAddingPromotionCode] = useState(false);
const removePromotionCode = async (code: string) => { const removePromotionCode = async (code: string) => {
const appliedCodes = promotions const appliedCodes = promotions
.filter((p) => p.code !== undefined) .filter((p) => p.code !== undefined)
@@ -54,37 +55,43 @@ export default function DiscountCode({
}; };
const addPromotionCode = async (code: string) => { const addPromotionCode = async (code: string) => {
if (!code || code.length === 0) {
return;
}
setIsAddingPromotionCode(true);
const loading = toast.loading(t('cart:discountCode.addLoading')); const loading = toast.loading(t('cart:discountCode.addLoading'));
const result = await addPromotionCodeAction(code); const result = await addPromotionCodeAction(code);
toast.dismiss(loading); toast.dismiss(loading);
if (result.success) { if (result.success) {
toast.success(t('cart:discountCode.addSuccess')); toast.success(t('cart:discountCode.addSuccess'));
setValue('code', ''); form.reset();
} else { } else {
toast.error(t('cart:discountCode.addError')); toast.error(t('cart:discountCode.addError'));
} }
setIsAddingPromotionCode(false);
}; };
const form = useForm<z.infer<typeof DiscountCodeSchema>>({
defaultValues: {
code: '',
},
resolver: zodResolver(DiscountCodeSchema),
});
return ( return (
<div className="txt-medium flex h-full w-full flex-col gap-y-4 bg-white"> <div className="txt-medium flex h-full w-full flex-col gap-y-4 bg-white">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
<Trans i18nKey={'cart:discountCode.subtitle'} /> <Trans i18nKey={'cart:discountCode.subtitle'} />
</p> </p>
<div className="mb-2 flex w-full flex-1 flex-col gap-x-2 gap-y-2 sm:flex-row"> <Form {...form}>
<form
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
className="mb-2 flex w-full flex-1 flex-col gap-x-2 gap-y-2 sm:flex-row"
>
<FormField <FormField
name={'code'} name={'code'}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormControl> <FormControl>
<Input <Input
required
type="text" type="text"
{...field} {...field}
placeholder={t('cart:discountCode.placeholder')} placeholder={t('cart:discountCode.placeholder')}
@@ -94,16 +101,11 @@ export default function DiscountCode({
)} )}
/> />
<Button <Button type="submit" variant="secondary" className="h-min">
type="button"
variant="secondary"
className="h-min"
onClick={() => addPromotionCode(currentValue)}
disabled={isAddingPromotionCode}
>
<Trans i18nKey={'cart:discountCode.apply'} /> <Trans i18nKey={'cart:discountCode.apply'} />
</Button> </Button>
</div> </form>
</Form>
{promotions.length > 0 && ( {promotions.length > 0 && (
<div className="mt-4 flex w-full items-center"> <div className="mt-4 flex w-full items-center">

View File

@@ -1,24 +1,27 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { formatCurrency } from '@/packages/shared/src/utils';
import { StoreCart, StoreCartLineItem } from '@medusajs/types';
import { import { Loader2 } from 'lucide-react';
StoreCart,
StoreCartLineItem,
StoreCartPromotion,
} from '@medusajs/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AccountBalanceSummary } from '@kit/accounts/services/account-balance.service'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans'; 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 { initiatePayment } from '../../_lib/server/cart-actions';
import CartForm, { CartFormOnSubmit } from './cart-form'; import { useRouter } from 'next/navigation';
import CartFormContent from './cart-form-content'; import { AccountBalanceSummary } from '@kit/accounts/services/account-balance.service';
import { EnrichedCartItem } from './types'; import { EnrichedCartItem } from './types';
const IS_DISCOUNT_SHOWN = true as boolean;
export default function Cart({ export default function Cart({
accountId, accountId,
cart, cart,
@@ -27,11 +30,7 @@ export default function Cart({
balanceSummary, balanceSummary,
}: { }: {
accountId: string; accountId: string;
cart: cart: StoreCart | null;
| (StoreCart & {
promotions: StoreCartPromotion[];
})
| null;
synlabAnalyses: StoreCartLineItem[]; synlabAnalyses: StoreCartLineItem[];
ttoServiceItems: EnrichedCartItem[]; ttoServiceItems: EnrichedCartItem[];
balanceSummary: AccountBalanceSummary | null; balanceSummary: AccountBalanceSummary | null;
@@ -45,47 +44,6 @@ export default function Cart({
const [unavailableLineItemIds, setUnavailableLineItemIds] = const [unavailableLineItemIds, setUnavailableLineItemIds] =
useState<string[]>(); useState<string[]>();
const getBalanceSummarySelection = useCallback(
({
useCompanyBenefits,
}: {
useCompanyBenefits: boolean;
}): {
benefitsAmount: number;
benefitsAmountTotal: number;
montonioAmount: number;
} => {
if (!cart) {
return {
benefitsAmount: 0,
benefitsAmountTotal: 0,
montonioAmount: 0,
};
}
const benefitsAmountTotal = balanceSummary?.totalBalance ?? 0;
const cartTotal = cart.total;
if (!useCompanyBenefits) {
return {
benefitsAmount: 0,
benefitsAmountTotal,
montonioAmount: cartTotal,
};
}
const benefitsAmount =
benefitsAmountTotal > cartTotal ? cartTotal : benefitsAmountTotal;
const montonioAmount =
benefitsAmount > 0 ? cartTotal - benefitsAmount : cartTotal;
return {
benefitsAmount,
benefitsAmountTotal,
montonioAmount,
};
},
[balanceSummary, cart],
);
const items = cart?.items ?? []; const items = cart?.items ?? [];
const hasCartItems = cart && Array.isArray(items) && items.length > 0; const hasCartItems = cart && Array.isArray(items) && items.length > 0;
@@ -109,17 +67,13 @@ export default function Cart({
); );
} }
const initiateSession: CartFormOnSubmit = async ({ useCompanyBenefits }) => { async function initiateSession() {
setIsInitiatingSession(true); setIsInitiatingSession(true);
try { try {
const { benefitsAmount } = getBalanceSummarySelection({ const { url, isFullyPaidByBenefits, orderId, unavailableLineItemIds } = await initiatePayment({
useCompanyBenefits,
});
const { url, isFullyPaidByBenefits, orderId, unavailableLineItemIds } =
await initiatePayment({
accountId, accountId,
benefitsAmount, balanceSummary: balanceSummary!,
cart: cart!, cart: cart!,
language, language,
}); });
@@ -138,20 +92,142 @@ export default function Cart({
console.error('Failed to initiate payment', error); console.error('Failed to initiate payment', error);
setIsInitiatingSession(false); setIsInitiatingSession(false);
} }
}; }
const isLocationsShown = synlabAnalyses.length > 0;
const companyBenefitsTotal = balanceSummary?.totalBalance ?? 0;
const montonioTotal = cart && companyBenefitsTotal > 0 ? cart.total - companyBenefitsTotal : cart.total;
return ( return (
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 lg:px-4"> <div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 lg:px-4">
<CartForm cart={cart} onSubmit={initiateSession}> <div className="flex flex-col gap-y-6 bg-white">
<CartFormContent <CartItems
cart={cart} cart={cart}
synlabAnalyses={synlabAnalyses} items={synlabAnalyses}
ttoServiceItems={ttoServiceItems} productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel"
unavailableLineItemIds={unavailableLineItemIds}
isInitiatingSession={isInitiatingSession}
getBalanceSummarySelection={getBalanceSummarySelection}
/> />
</CartForm> <CartServiceItems
cart={cart}
items={ttoServiceItems}
productColumnLabelKey="cart:items.ttoServices.productColumnLabel"
unavailableLineItemIds={unavailableLineItemIds}
/>
</div>
{hasCartItems && (
<>
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.subtotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.promotionsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
{companyBenefitsTotal > 0 && (
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.companyBenefitsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: (companyBenefitsTotal > cart.total ? cart.total : companyBenefitsTotal),
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
)}
<div className="flex gap-x-4 px-4 sm:justify-end sm:px-6">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.total" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: montonioTotal < 0 ? 0 : montonioTotal,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
</>
)}
<div className="flex flex-col gap-x-4 gap-y-6 py-4 sm:flex-row sm:py-8">
{IS_DISCOUNT_SHOWN && (
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:discountCode.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<DiscountCode cart={{ ...cart }} />
</CardContent>
</Card>
)}
{isLocationsShown && (
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:locations.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<AnalysisLocation
cart={{ ...cart }}
synlabAnalyses={synlabAnalyses}
/>
</CardContent>
</Card>
)}
</div>
<div>
<Button
className="h-10"
onClick={initiateSession}
disabled={isInitiatingSession}
>
{isInitiatingSession && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -1,56 +0,0 @@
import React from 'react';
import { formatCurrency } from '@/packages/shared/src/utils';
import { StoreCartLineItem } from '@medusajs/types';
import { useTranslation } from 'react-i18next';
import { Table, TableBody } from '@kit/ui/shadcn/table';
import MobileTableRow from './mobile-table-row';
const MobileCartItems = ({
item,
currencyCode,
productColumnLabelKey,
}: {
item: StoreCartLineItem;
currencyCode: string;
productColumnLabelKey: string;
}) => {
const {
i18n: { language, t },
} = useTranslation();
return (
<Table className="border-separate rounded-lg border p-2">
<TableBody>
<MobileTableRow
title={t(productColumnLabelKey)}
value={item.product_title}
/>
<MobileTableRow title={t('cart:table.time')} value={item.quantity} />
<MobileTableRow
title={t('cart:table.price')}
value={formatCurrency({
value: item.unit_price,
currencyCode,
locale: language,
})}
/>
<MobileTableRow
title={t('cart:table.total')}
value={
item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})
}
/>
</TableBody>
</Table>
);
};
export default MobileCartItems;

View File

@@ -1,94 +0,0 @@
import React from 'react';
import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils';
import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import { Table, TableBody, TableCell, TableRow } from '@kit/ui/shadcn/table';
import CartItemDelete from './cart-item-delete';
import MobileTableRow from './mobile-table-row';
import { EnrichedCartItem } from './types';
const MobileCartServiceItems = ({
item,
currencyCode,
isUnavailable,
productColumnLabelKey,
setEditingItem,
}: {
item: EnrichedCartItem;
currencyCode: string;
isUnavailable?: boolean;
productColumnLabelKey: string;
setEditingItem: (item: EnrichedCartItem | null) => void;
}) => {
const {
i18n: { language, t },
} = useTranslation();
return (
<Table className="border-separate rounded-lg border p-2">
<TableBody>
<MobileTableRow
title={t(productColumnLabelKey)}
value={item.product_title}
/>
<MobileTableRow
title={t('cart:table.time')}
value={formatDateAndTime(item.reservation.startTime.toString())}
/>
<MobileTableRow
title={t('cart:table.location')}
value={item.reservation.location?.address ?? '-'}
/>
<MobileTableRow
title={t('cart:table.quantity')}
value={item.quantity}
/>
<MobileTableRow
title={t('cart:table.price')}
value={formatCurrency({
value: item.unit_price,
currencyCode,
locale: language,
})}
/>
<MobileTableRow
title={t('cart:table.total')}
value={
item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})
}
/>
<TableRow>
<TableCell />
<TableCell className="flex w-full items-center justify-end gap-4 p-0 pt-2">
<CartItemDelete id={item.id} />
<Button type="button" onClick={() => setEditingItem(item)}>
<Trans i18nKey="common:change" />
</Button>
</TableCell>
</TableRow>
{isUnavailable && (
<TableRow>
<TableCell
colSpan={8}
className="text-destructive px-4 text-left sm:px-6"
>
<Trans i18nKey="booking:timeSlotUnavailable" />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
};
export default MobileCartServiceItems;

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { TableCell, TableHead, TableRow } from '@kit/ui/shadcn/table';
const MobleTableRow = ({
title,
value,
}: {
title?: string;
value?: string | number;
}) => (
<TableRow>
<TableHead className="h-2 font-bold">{title}</TableHead>
<TableCell className="p-0 text-right">
<p className="txt-medium-plus text-ui-fg-base">{value}</p>
</TableCell>
</TableRow>
);
export default MobleTableRow;

View File

@@ -1,6 +1,5 @@
import { StoreCartLineItem } from '@medusajs/types'; import { StoreCartLineItem } from "@medusajs/types";
import { Reservation } from "~/lib/types/reservation";
import { Reservation } from '~/lib/types/reservation';
export interface MontonioOrderToken { export interface MontonioOrderToken {
uuid: string; uuid: string;
@@ -14,6 +13,12 @@ export interface MontonioOrderToken {
| 'PENDING' | 'PENDING'
| 'EXPIRED' | 'EXPIRED'
| 'REFUNDED'; | 'REFUNDED';
| 'PAID'
| 'FAILED'
| 'CANCELLED'
| 'PENDING'
| 'EXPIRED'
| 'REFUNDED';
paymentMethod: string; paymentMethod: string;
grandTotal: number; grandTotal: number;
currency: string; currency: string;
@@ -31,13 +36,3 @@ export enum CartItemType {
} }
export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation }; export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation };
export type GetBalanceSummarySelection = ({
useCompanyBenefits,
}: {
useCompanyBenefits: boolean;
}) => {
benefitsAmount: number;
benefitsAmountTotal: number;
montonioAmount: number;
};

View File

@@ -1,11 +0,0 @@
'use client';
import { Check } from 'lucide-react';
export const CheckWithBackground = () => {
return (
<div className="bg-primary w-min rounded-full p-1 text-white">
<Check className="size-3 stroke-2" />
</div>
);
};

View File

@@ -1,115 +0,0 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AnalysisPackageWithVariant } from '@/packages/shared/src/components/select-analysis-package';
import { pathsConfig } from '@/packages/shared/src/config';
import { Spinner } from '@kit/ui/makerkit/spinner';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import { toast } from '@kit/ui/shadcn/sonner';
import { Table, TableBody, TableCell, TableRow } from '@kit/ui/shadcn/table';
import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { cn } from '~/lib/utils';
const AddToCartButton = ({
onClick,
disabled,
isLoading,
}: {
onClick: () => void;
disabled: boolean;
isLoading: boolean;
}) => {
return (
<TableCell align="center" className="xs:px-2 px-1 py-6">
<Button
onClick={onClick}
disabled={disabled}
className="xs:p-6 xs:text-sm relative p-2 text-[10px]"
>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner />
</div>
)}
<div
className={cn({
invisible: isLoading,
})}
>
<Trans i18nKey="compare-packages-modal:selectThisPackage" />
</div>
</Button>
</TableCell>
);
};
const ComparePackagesAddToCartButtons = ({
countryCode,
standardPackage,
standardPlusPackage,
premiumPackage,
}: {
countryCode: string;
standardPackage: AnalysisPackageWithVariant;
standardPlusPackage: AnalysisPackageWithVariant;
premiumPackage: AnalysisPackageWithVariant;
}) => {
const [addedPackage, setAddedPackage] = useState<string | null>(null);
const router = useRouter();
const handleSelect = async ({ variantId }: AnalysisPackageWithVariant) => {
setAddedPackage(variantId);
try {
await handleAddToCart({
selectedVariant: { id: variantId },
countryCode,
});
setAddedPackage(null);
toast.success(
<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />,
);
router.push(pathsConfig.app.cart);
} catch (e) {
toast.error(
<Trans
i18nKey={'order-analysis-package:analysisPackageAddToCartError'}
/>,
);
setAddedPackage(null);
console.error(e);
}
};
return (
<Table>
<TableBody>
<TableRow>
<TableCell className="w-[30vw] py-6" />
<AddToCartButton
onClick={() => handleSelect(standardPackage)}
disabled={!!addedPackage}
isLoading={addedPackage === standardPackage.variantId}
/>
<AddToCartButton
onClick={() => handleSelect(standardPlusPackage)}
disabled={!!addedPackage}
isLoading={addedPackage === standardPlusPackage.variantId}
/>
<AddToCartButton
onClick={() => handleSelect(premiumPackage)}
disabled={!!addedPackage}
isLoading={addedPackage === premiumPackage.variantId}
/>
</TableRow>
</TableBody>
</Table>
);
};
export default ComparePackagesAddToCartButtons;

View File

@@ -3,7 +3,7 @@ import { JSX } from 'react';
import { StoreProduct } from '@medusajs/types'; import { StoreProduct } from '@medusajs/types';
import { QuestionMarkCircledIcon } from '@radix-ui/react-icons'; import { QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { X } from 'lucide-react'; import { Check, X } from 'lucide-react';
import { PackageHeader } from '@kit/shared/components/package-header'; import { PackageHeader } from '@kit/shared/components/package-header';
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package'; import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
@@ -26,10 +26,6 @@ import {
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { CheckWithBackground } from './check-with-background';
import ComparePackagesAddToCartButtons from './compare-packages-add-to-cart-buttons';
import DefaultPackageFeaturesRows from './default-package-features-rows';
export type AnalysisPackageElement = Pick< export type AnalysisPackageElement = Pick<
StoreProduct, StoreProduct,
'title' | 'id' | 'description' 'title' | 'id' | 'description'
@@ -39,6 +35,14 @@ export type AnalysisPackageElement = Pick<
isIncludedInPremium: boolean; isIncludedInPremium: boolean;
}; };
const CheckWithBackground = () => {
return (
<div className="bg-primary w-min rounded-full p-1 text-white">
<Check className="size-3 stroke-2" />
</div>
);
};
const PackageTableHead = async ({ const PackageTableHead = async ({
product, product,
}: { }: {
@@ -49,7 +53,7 @@ const PackageTableHead = async ({
const { title, price, nrOfAnalyses } = product; const { title, price, nrOfAnalyses } = product;
return ( return (
<TableHead className="xs:content-normal content-start py-2"> <TableHead className="py-2">
<PackageHeader <PackageHeader
title={t(title)} title={t(title)}
tagColor="bg-cyan" tagColor="bg-cyan"
@@ -65,12 +69,10 @@ const ComparePackagesModal = async ({
analysisPackages, analysisPackages,
analysisPackageElements, analysisPackageElements,
triggerElement, triggerElement,
countryCode,
}: { }: {
analysisPackages: AnalysisPackageWithVariant[]; analysisPackages: AnalysisPackageWithVariant[];
analysisPackageElements: AnalysisPackageElement[]; analysisPackageElements: AnalysisPackageElement[];
triggerElement: JSX.Element; triggerElement: JSX.Element;
countryCode: string;
}) => { }) => {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -90,7 +92,7 @@ const ComparePackagesModal = async ({
<DialogContent <DialogContent
className="min-h-screen max-w-fit min-w-screen" className="min-h-screen max-w-fit min-w-screen"
customClose={ customClose={
<div className="absolute top-6 right-0 flex place-items-center-safe sm:top-0"> <div className="inline-flex place-items-center-safe gap-1 align-middle">
<p className="text-sm font-medium text-black"> <p className="text-sm font-medium text-black">
{t('common:close')} {t('common:close')}
</p> </p>
@@ -104,13 +106,11 @@ const ComparePackagesModal = async ({
</VisuallyHidden> </VisuallyHidden>
<div className="m-auto"> <div className="m-auto">
<div className="space-y-6 text-center"> <div className="space-y-6 text-center">
<h3 className="sm:text-xxl text-lg"> <h3>{t('product:healthPackageComparison.label')}</h3>
{t('product:healthPackageComparison.label')} <p className="text-muted-foreground mx-auto w-3/5 text-sm">
</h3>
<p className="text-muted-foreground text-sm sm:mx-auto sm:w-3/5">
{t('product:healthPackageComparison.description')} {t('product:healthPackageComparison.description')}
</p> </p>
<div className="max-h-[50vh] overflow-y-auto rounded-md border sm:max-h-[70vh]"> <div className="max-h-[80vh] overflow-y-auto rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -121,8 +121,6 @@ const ComparePackagesModal = async ({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<DefaultPackageFeaturesRows />
{analysisPackageElements.map( {analysisPackageElements.map(
({ ({
title, title,
@@ -138,14 +136,12 @@ const ComparePackagesModal = async ({
return ( return (
<TableRow key={id}> <TableRow key={id}>
<TableCell className="relative py-6 sm:w-[30vw]"> <TableCell className="py-6 sm:max-w-[30vw]">
{title}{' '} {title}{' '}
{description && ( {description && (
<InfoTooltip <InfoTooltip
content={description} content={description}
icon={ icon={<QuestionMarkCircledIcon />}
<QuestionMarkCircledIcon className="absolute top-2 right-0 size-5 sm:static sm:ml-2 sm:size-4" />
}
/> />
)} )}
</TableCell> </TableCell>
@@ -168,12 +164,6 @@ const ComparePackagesModal = async ({
</Table> </Table>
</div> </div>
</div> </div>
<ComparePackagesAddToCartButtons
countryCode={countryCode}
standardPackage={standardPackage}
premiumPackage={premiumPackage}
standardPlusPackage={standardPlusPackage}
/>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,7 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { formatCurrency } from '@/packages/shared/src/utils';
import { ChevronRight, HeartPulse } from 'lucide-react'; import { ChevronRight, HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -13,27 +11,25 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { cn } from '@kit/ui/lib/utils';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { formatCurrency } from '@/packages/shared/src/utils';
import { getAccountBalanceSummary } from '../_lib/server/balance-actions'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { cn } from '@kit/ui/lib/utils';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
import { getAccountBalanceSummary } from '../_lib/server/balance-actions';
export default async function DashboardCards() { export default async function DashboardCards() {
const { language } = await createI18nServerInstance(); const { language } = await createI18nServerInstance();
const { account } = await loadCurrentUserAccount(); const { account } = await loadCurrentUserAccount();
const balanceSummary = account const balanceSummary = account ? await getAccountBalanceSummary(account.id) : null;
? await getAccountBalanceSummary(account.id)
: null;
return ( return (
<div <div
className={cn( className={cn(
'grid grid-cols-1 gap-4', 'grid grid-cols-1 gap-4',
'md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', 'md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
)} )}>
>
<Card <Card
variant="gradient-success" variant="gradient-success"
className="xs:w-1/2 flex w-full flex-col justify-between sm:w-auto" className="xs:w-1/2 flex w-full flex-col justify-between sm:w-auto"

View File

@@ -14,10 +14,7 @@ import {
User, User,
} from 'lucide-react'; } from 'lucide-react';
import type { import type { AccountWithParams, BmiThresholds } from '@kit/accounts/types/accounts';
AccountWithParams,
BmiThresholds,
} from '@kit/accounts/types/accounts';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {

View File

@@ -1,57 +0,0 @@
'use client';
import React from 'react';
import { InfoTooltip } from '@/packages/shared/src/components/ui/info-tooltip';
import { QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { Trans } from '@kit/ui/makerkit/trans';
import { TableCell, TableRow } from '@kit/ui/shadcn/table';
import { CheckWithBackground } from './check-with-background';
const DefaultPackageFeaturesRows = () => {
return (
<>
<TableRow key="digital-doctor-feedback">
<TableCell className="relative max-w-[30vw] py-6">
<Trans i18nKey="order-analysis-package:digitalDoctorFeedback" />
<InfoTooltip
content={
<Trans i18nKey="order-analysis-package:digitalDoctorFeedbackInfo" />
}
icon={
<QuestionMarkCircledIcon className="absolute top-2 right-0 size-5 sm:static sm:ml-2 sm:size-4" />
}
/>
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
</TableRow>
<TableRow key="give-analyses">
<TableCell className="py-6 sm:max-w-[30vw]">
<Trans i18nKey="order-analysis-package:giveAnalyses" />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
</TableRow>
</>
);
};
export default DefaultPackageFeaturesRows;

View File

@@ -9,12 +9,12 @@ import { ShoppingCart } from 'lucide-react';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container'; import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import { Search } from '@kit/shared/components/ui/search';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card } from '@kit/ui/shadcn/card'; import { Card } from '@kit/ui/shadcn/card';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { UserNotifications } from '../_components/user-notifications'; import { UserNotifications } from '../_components/user-notifications';
import { getAccountBalanceSummary } from '../_lib/server/balance-actions';
import { type UserWorkspace } from '../_lib/server/load-user-workspace'; import { type UserWorkspace } from '../_lib/server/load-user-workspace';
export async function HomeMenuNavigation(props: { export async function HomeMenuNavigation(props: {
@@ -23,9 +23,13 @@ export async function HomeMenuNavigation(props: {
}) { }) {
const { language } = await createI18nServerInstance(); const { language } = await createI18nServerInstance();
const { workspace, user, accounts } = props.workspace; const { workspace, user, accounts } = props.workspace;
const balanceSummary = workspace?.id const totalValue = props.cart?.total
? await getAccountBalanceSummary(workspace.id) ? formatCurrency({
: null; currencyCode: props.cart.currency_code,
locale: language,
value: props.cart.total,
})
: 0;
const cartQuantityTotal = const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0; props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
@@ -43,32 +47,29 @@ export async function HomeMenuNavigation(props: {
/> */} /> */}
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
{/* TODO: add wallet functionality
<Card className="px-6 py-2"> <Card className="px-6 py-2">
<span> <span>€ {Number(0).toFixed(2).replace('.', ',')}</span>
{formatCurrency({
value: balanceSummary?.totalBalance || 0,
locale: language,
currencyCode: 'EUR',
})}
</span>
</Card> </Card>
*/}
{hasCartItems && (
<Button
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
variant="ghost"
>
<span className="flex items-center text-nowrap">{totalValue}</span>
</Button>
)}
<Link href={pathsConfig.app.cart}> <Link href={pathsConfig.app.cart}>
<Button <Button
variant="ghost" variant="ghost"
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2" className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
> >
<ShoppingCart className="stroke-[1.5px]" /> <ShoppingCart className="stroke-[1.5px]" />
<Trans i18nKey="common:shoppingCart" />{' '} <Trans
{hasCartItems && ( i18nKey="common:shoppingCartCount"
<> values={{ count: cartQuantityTotal }}
( />
<span className="text-success font-bold">
{cartQuantityTotal}
</span>
)
</>
)}
</Button> </Button>
</Link> </Link>
<UserNotifications userId={user.id} /> <UserNotifications userId={user.id} />

View File

@@ -25,22 +25,16 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/shadcn';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/shadcn/avatar';
import { Button } from '@kit/ui/shadcn/button';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
// home imports // home imports
import type { UserWorkspace } from '../_lib/server/load-user-workspace'; import type { UserWorkspace } from '../_lib/server/load-user-workspace';
import { UserNotifications } from './user-notifications';
const PERSONAL_ACCOUNT_SLUG = 'personal';
export function HomeMobileNavigation(props: { export function HomeMobileNavigation(props: {
workspace: UserWorkspace; workspace: UserWorkspace;
cart: StoreCart | null; cart: StoreCart | null;
}) { }) {
const { user, accounts } = props.workspace; const user = props.workspace.user;
const signOut = useSignOut(); const signOut = useSignOut();
const { data: personalAccountData } = usePersonalAccountData(user.id); const { data: personalAccountData } = usePersonalAccountData(user.id);
@@ -82,8 +76,8 @@ export function HomeMobileNavigation(props: {
const hasDoctorRole = const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor; personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole; return hasDoctorRole && hasTotpFactor;
}, [personalAccountData]); }, [user, personalAccountData, hasTotpFactor]);
const cartQuantityTotal = const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0; props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
@@ -91,31 +85,10 @@ export function HomeMobileNavigation(props: {
return ( return (
<DropdownMenu> <DropdownMenu>
<div className="flex justify-between gap-3">
<Link href={pathsConfig.app.cart}>
<Button
variant="ghost"
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
>
<ShoppingCart className="stroke-[1.5px]" />
{hasCartItems && (
<>
(
<span className="text-success font-bold">
{cartQuantityTotal}
</span>
)
</>
)}
</Button>
</Link>
<UserNotifications userId={user.id} />
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Menu className="h-6 w-6" /> <Menu className={'h-9'} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
</div>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}> <DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={props.cart && hasCartItems}> <If condition={props.cart && hasCartItems}>
<DropdownMenuGroup> <DropdownMenuGroup>
@@ -175,46 +148,6 @@ export function HomeMobileNavigation(props: {
</If> </If>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<If condition={accounts.length > 0}>
<span className="text-muted-foreground px-2 text-xs">
<Trans
i18nKey={'teams:yourTeams'}
values={{ teamsCount: accounts.length }}
/>
</span>
{accounts.map((account) => (
<DropdownMenuItem key={account.value} asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={`${pathsConfig.app.home}/${account.value}`}
>
<div className={'flex items-center'}>
<Avatar className={'h-5 w-5 rounded-xs ' + account.image}>
<AvatarImage
{...(account.image && { src: account.image })}
/>
<AvatarFallback
className={cn('rounded-md', {
['bg-background']:
PERSONAL_ACCOUNT_SLUG === account.value,
['group-hover:bg-background']:
PERSONAL_ACCOUNT_SLUG !== account.value,
})}
>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span className={'pl-3'}>{account.label}</span>
</div>
</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</If>
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} /> <SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -57,7 +57,9 @@ export default function OrderAnalysesCards({
} }
}; };
return analyses.map(({ title, variant, description, subtitle, price }) => { return (
<div className="xs:grid-cols-3 mt-4 grid gap-6">
{analyses.map(({ title, variant, description, subtitle, price }) => {
const formattedPrice = const formattedPrice =
typeof price === 'number' typeof price === 'number'
? formatCurrency({ ? formatCurrency({
@@ -73,7 +75,11 @@ export default function OrderAnalysesCards({
className="flex flex-col justify-between" className="flex flex-col justify-between"
> >
<CardHeader className="flex-row"> <CardHeader className="flex-row">
<div className="bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white"> <div
className={
'bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white'
}
>
<HeartPulse className="size-4 fill-green-500" /> <HeartPulse className="size-4 fill-green-500" />
</div> </div>
<div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white"> <div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
@@ -116,5 +122,7 @@ export default function OrderAnalysesCards({
</CardFooter> </CardFooter>
</Card> </Card>
); );
}); })}
</div>
);
} }

View File

@@ -5,7 +5,7 @@ import Link from 'next/link';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { pathsConfig } from '@/packages/shared/src/config'; import { pathsConfig } from '@/packages/shared/src/config';
import { ComponentInstanceIcon } from '@radix-ui/react-icons'; import { ComponentInstanceIcon } from '@radix-ui/react-icons';
import { ChevronRight } from 'lucide-react'; import { ChevronRight, HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {

View File

@@ -9,8 +9,8 @@ import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
const PaymentProviderIds = { const PaymentProviderIds = {
COMPANY_BENEFITS: 'pp_company-benefits_company-benefits', COMPANY_BENEFITS: "pp_company-benefits_company-benefits",
MONTONIO: 'pp_montonio_montonio', MONTONIO: "pp_montonio_montonio",
}; };
export default function CartTotals({ export default function CartTotals({
@@ -30,12 +30,10 @@ export default function CartTotals({
payment_collections, payment_collections,
} = medusaOrder; } = medusaOrder;
const montonioPayment = payment_collections?.[0]?.payments?.find( const montonioPayment = payment_collections?.[0]?.payments
({ provider_id }) => provider_id === PaymentProviderIds.MONTONIO, ?.find(({ provider_id }) => provider_id === PaymentProviderIds.MONTONIO);
); const companyBenefitsPayment = payment_collections?.[0]?.payments
const companyBenefitsPayment = payment_collections?.[0]?.payments?.find( ?.find(({ provider_id }) => provider_id === PaymentProviderIds.COMPANY_BENEFITS);
({ provider_id }) => provider_id === PaymentProviderIds.COMPANY_BENEFITS,
);
return ( return (
<div> <div>
@@ -98,6 +96,7 @@ export default function CartTotals({
</span> </span>
</div> </div>
)} )}
</div> </div>
<div className="my-4 h-px w-full border-b border-gray-200" /> <div className="my-4 h-px w-full border-b border-gray-200" />
@@ -127,10 +126,7 @@ export default function CartTotals({
<span className="flex items-center gap-x-1"> <span className="flex items-center gap-x-1">
<Trans i18nKey="cart:order.benefitsTotal" /> <Trans i18nKey="cart:order.benefitsTotal" />
</span> </span>
<span <span data-testid="cart-subtotal" data-value={companyBenefitsPayment.amount || 0}>
data-testid="cart-subtotal"
data-value={companyBenefitsPayment.amount || 0}
>
-{' '} -{' '}
{formatCurrency({ {formatCurrency({
value: companyBenefitsPayment.amount ?? 0, value: companyBenefitsPayment.amount ?? 0,
@@ -146,10 +142,7 @@ export default function CartTotals({
<span className="flex items-center gap-x-1"> <span className="flex items-center gap-x-1">
<Trans i18nKey="cart:order.montonioTotal" /> <Trans i18nKey="cart:order.montonioTotal" />
</span> </span>
<span <span data-testid="cart-subtotal" data-value={montonioPayment.amount || 0}>
data-testid="cart-subtotal"
data-value={montonioPayment.amount || 0}
>
-{' '} -{' '}
{formatCurrency({ {formatCurrency({
value: montonioPayment.amount ?? 0, value: montonioPayment.amount ?? 0,

View File

@@ -1,63 +1,27 @@
import { formatDate } from 'date-fns'; import { formatDate } from 'date-fns';
import { Database } from '@kit/supabase/database';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
export default function OrderDetails({ export default function OrderDetails({
order, order,
}: { }: {
order: { order: { id: string; created_at: string | Date };
id: string;
created_at: string | Date;
location: Pick<
Database['medreport']['Tables']['connected_online_locations']['Row'],
'name' | 'address'
> | null;
serviceProvider: Pick<
Database['medreport']['Tables']['connected_online_providers']['Row'],
'email' | 'phone_number' | 'name'
> | null;
};
}) { }) {
const { id, created_at, location, serviceProvider } = order;
return ( return (
<div className="flex flex-col gap-y-2"> <div className="flex flex-col gap-y-2">
<div> <div>
<span className="font-bold"> <span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.orderNumber" />:{' '} <Trans i18nKey="cart:orderConfirmed.orderNumber" />:{' '}
</span> </span>
<span className="break-all">{id}</span> <span className="break-all">{order.id}</span>
</div> </div>
<div> <div>
<span className="font-bold"> <span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{' '} <Trans i18nKey="cart:orderConfirmed.orderDate" />:{' '}
</span> </span>
<span>{formatDate(created_at, 'dd.MM.yyyy HH:mm')}</span> <span>{formatDate(order.created_at, 'dd.MM.yyyy HH:mm')}</span>
</div> </div>
{(location?.name || location?.address) && (
<div>
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.location" />:{' '}
</span>
<span>
{location.name || location.address}{' '}
{location?.name ? location.address : ''}
</span>
</div>
)}
{serviceProvider && (
<div className="flex flex-col">
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.serviceProvider" />:{' '}
</span>
<span>{serviceProvider.name}</span>
<span>{serviceProvider.phone_number}</span>
<span>{serviceProvider.email}</span>
</div>
)}
</div> </div>
); );
} }

View File

@@ -5,22 +5,20 @@ import { Eye } from 'lucide-react';
import { Trans } from '@kit/ui/makerkit/trans'; import { Trans } from '@kit/ui/makerkit/trans';
import type { AnalysisOrder, TTOOrder } from '~/lib/types/order'; import type { AnalysisOrder } from '~/lib/types/order';
import OrderItemsTable from './order-items-table'; import OrderItemsTable from './order-items-table';
export default function OrderBlock({ export default function OrderBlock({
analysisOrder, analysisOrder,
ttoLocation, medusaOrderStatus,
ttoReservation,
itemsAnalysisPackage, itemsAnalysisPackage,
itemsTtoService, itemsTtoService,
itemsOther, itemsOther,
medusaOrderId, medusaOrderId,
}: { }: {
analysisOrder?: AnalysisOrder; analysisOrder?: AnalysisOrder;
ttoLocation?: { name: string }; medusaOrderStatus: string;
ttoReservation?: TTOOrder;
itemsAnalysisPackage: StoreOrderLineItem[]; itemsAnalysisPackage: StoreOrderLineItem[];
itemsTtoService: StoreOrderLineItem[]; itemsTtoService: StoreOrderLineItem[];
itemsOther: StoreOrderLineItem[]; itemsOther: StoreOrderLineItem[];
@@ -40,7 +38,7 @@ export default function OrderBlock({
<Trans i18nKey={`orders:status.${analysisOrder.status}`} /> <Trans i18nKey={`orders:status.${analysisOrder.status}`} />
</h5> </h5>
<Link <Link
href={`/home/order/${medusaOrderId}`} href={`/home/order/${analysisOrder.id}`}
className="text-small-regular flex items-center justify-between" className="text-small-regular flex items-center justify-between"
> >
<button className="text-ui-fg-subtle hover:text-ui-fg-base flex cursor-pointer gap-x-1"> <button className="text-ui-fg-subtle hover:text-ui-fg-base flex cursor-pointer gap-x-1">
@@ -49,7 +47,7 @@ export default function OrderBlock({
</Link> </Link>
</div> </div>
)} )}
<div className="flex flex-col sm:gap-4"> <div className="flex flex-col gap-4">
{analysisOrder && ( {analysisOrder && (
<OrderItemsTable <OrderItemsTable
items={itemsAnalysisPackage} items={itemsAnalysisPackage}
@@ -59,7 +57,6 @@ export default function OrderBlock({
id: analysisOrder.id, id: analysisOrder.id,
status: analysisOrder.status, status: analysisOrder.status,
}} }}
isPackage
/> />
)} )}
{itemsTtoService && ( {itemsTtoService && (
@@ -68,12 +65,8 @@ export default function OrderBlock({
title="orders:table.ttoService" title="orders:table.ttoService"
type="ttoService" type="ttoService"
order={{ order={{
status: ttoReservation?.status, status: medusaOrderStatus.toUpperCase(),
medusaOrderId, medusaOrderId,
location: ttoLocation?.name,
bookingCode: ttoReservation?.booking_code,
clinicId: ttoReservation?.clinic_id,
medusaLineItemId: ttoReservation?.medusa_cart_line_item_id,
}} }}
/> />
)} )}
@@ -81,8 +74,6 @@ export default function OrderBlock({
items={itemsOther} items={itemsOther}
title="orders:table.otherOrders" title="orders:table.otherOrders"
order={{ order={{
medusaOrderId: analysisOrder?.medusa_order_id,
id: analysisOrder?.id,
status: analysisOrder?.status, status: analysisOrder?.status,
}} }}
/> />

View File

@@ -1,13 +1,9 @@
'use client'; 'use client';
import React, { useState } from 'react'; import { useRouter } from 'next/navigation';
import { redirect, useRouter } from 'next/navigation';
import ConfirmationModal from '@/packages/shared/src/components/confirmation-modal';
import { StoreOrderLineItem } from '@medusajs/types'; import { StoreOrderLineItem } from '@medusajs/types';
import { formatDate } from 'date-fns'; import { formatDate } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -23,8 +19,6 @@ import { Trans } from '@kit/ui/trans';
import type { Order } from '~/lib/types/order'; import type { Order } from '~/lib/types/order';
import { cancelTtoBooking } from '../../_lib/server/actions';
import MobileTableRow from '../cart/mobile-table-row';
import { logAnalysisResultsNavigateAction } from './actions'; import { logAnalysisResultsNavigateAction } from './actions';
export type OrderItemType = 'analysisOrder' | 'ttoService'; export type OrderItemType = 'analysisOrder' | 'ttoService';
@@ -34,31 +28,19 @@ export default function OrderItemsTable({
title, title,
order, order,
type = 'analysisOrder', type = 'analysisOrder',
isPackage = false,
}: { }: {
items: StoreOrderLineItem[]; items: StoreOrderLineItem[];
title: string; title: string;
order: Order; order: Order;
type?: OrderItemType; type?: OrderItemType;
isPackage?: boolean;
}) { }) {
const {
i18n: { t },
} = useTranslation();
const router = useRouter(); const router = useRouter();
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const isCancelOrderAllowed =
order?.bookingCode &&
order?.clinicId &&
order?.medusaLineItemId &&
order?.status === 'CONFIRMED';
if (!items || items.length === 0) { if (!items || items.length === 0) {
return null; return null;
} }
const isAnalysisOrder = type === 'analysisOrder'; const isAnalysisOrder = type === 'analysisOrder';
const isTtoservice = type === 'ttoService';
const openDetailedView = async () => { const openDetailedView = async () => {
if (isAnalysisOrder && order?.medusaOrderId && order?.id) { if (isAnalysisOrder && order?.medusaOrderId && order?.id) {
@@ -70,68 +52,7 @@ export default function OrderItemsTable({
}; };
return ( return (
<> <Table className="border-separate rounded-lg border">
<Table className="border-separate rounded-lg border p-2 sm:hidden">
<TableBody>
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((orderItem) => (
<React.Fragment key={`${orderItem.id}-mobile`}>
<MobileTableRow
title={t(title)}
value={orderItem.product_title || ''}
/>
<MobileTableRow
title={t('orders:table.createdAt')}
value={formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
/>
{order.location && (
<MobileTableRow
title={t('orders:table.location')}
value={order.location}
/>
)}
<MobileTableRow
title={t('orders:table.status')}
value={
isPackage
? t(
`orders:status.analysisPackageOrder.${order?.status ?? 'CONFIRMED'}`,
)
: t(
`orders:status.${type}.${order?.status ?? 'CONFIRMED'}`,
)
}
/>
<TableRow>
<TableCell />
<TableCell className="flex w-full items-center justify-end gap-2 p-0 pt-2">
<Button size="sm" onClick={openDetailedView}>
<Trans
i18nKey={
isTtoservice ? 'orders:view' : 'analysis-results:view'
}
/>
</Button>
{isTtoservice && order.bookingCode && (
<Button
size="sm"
className="bg-warning/90 hover:bg-warning"
onClick={() => setIsConfirmOpen(true)}
>
<Trans i18nKey="analysis-results:cancel" />
</Button>
)}
</TableCell>
</TableRow>
</React.Fragment>
))}
</TableBody>
</Table>
<Table className="hidden border-separate rounded-lg border sm:block">
<TableHeader className="text-ui-fg-subtle txt-medium-plus"> <TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow> <TableRow>
<TableHead className="px-6"> <TableHead className="px-6">
@@ -140,11 +61,6 @@ export default function OrderItemsTable({
<TableHead className="px-6"> <TableHead className="px-6">
<Trans i18nKey="orders:table.createdAt" /> <Trans i18nKey="orders:table.createdAt" />
</TableHead> </TableHead>
{order.location && (
<TableHead className="px-6">
<Trans i18nKey="orders:table.location" />
</TableHead>
)}
<TableHead className="px-6"> <TableHead className="px-6">
<Trans i18nKey="orders:table.status" /> <Trans i18nKey="orders:table.status" />
</TableHead> </TableHead>
@@ -167,62 +83,21 @@ export default function OrderItemsTable({
<TableCell className="px-6 whitespace-nowrap"> <TableCell className="px-6 whitespace-nowrap">
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')} {formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell> </TableCell>
{order.location && (
<TableCell className="min-w-[180px] px-6"> <TableCell className="min-w-[180px] px-6">
{order.location}
</TableCell>
)}
<TableCell className="min-w-[180px] px-6">
{isPackage ? (
<Trans
i18nKey={`orders:status.analysisPackageOrder.${order?.status ?? 'CONFIRMED'}`}
/>
) : (
<Trans <Trans
i18nKey={`orders:status.${type}.${order?.status ?? 'CONFIRMED'}`} i18nKey={`orders:status.${type}.${order?.status ?? 'CONFIRMED'}`}
/> />
)}
</TableCell> </TableCell>
<TableCell className="px-6 text-right"> <TableCell className="px-6 text-right">
<Button size="sm" onClick={openDetailedView}> <Button size="sm" onClick={openDetailedView}>
<Trans <Trans i18nKey="analysis-results:view" />
i18nKey={
isTtoservice ? 'orders:view' : 'analysis-results:view'
}
/>
</Button> </Button>
{isCancelOrderAllowed && (
<Button
size="sm"
className="bg-warning/90 hover:bg-warning mt-2 w-full"
onClick={() => setIsConfirmOpen(true)}
>
<Trans i18nKey="analysis-results:cancel" />
</Button>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
{isCancelOrderAllowed && (
<ConfirmationModal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={async () => {
cancelTtoBooking(
order.bookingCode!,
order.clinicId!,
order.medusaLineItemId!,
).then(() => {
redirect(pathsConfig.app.myOrders);
});
}}
titleKey="orders:confirmBookingCancel.title"
descriptionKey="orders:confirmBookingCancel.description"
/>
)}
</>
); );
} }

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { InfoTooltip } from '@/packages/shared/src/components/ui/info-tooltip';
import { HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/shadcn/button';
import {
Card,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/shadcn/card';
import { Skeleton } from '@kit/ui/skeleton';
const RecommendationsSkeleton = () => {
const emptyData = [
{
title: '1',
description: '',
subtitle: '',
variant: { id: '' },
price: 1,
},
{
title: '2',
description: '',
subtitle: '',
variant: { id: '' },
price: 1,
},
];
return (
<div className="xs:grid-cols-3 mt-4 grid gap-6">
{emptyData.map(({ title, description, subtitle }) => (
<Skeleton key={title}>
<Card>
<CardHeader className="flex-row">
<div
className={
'mb-6 flex size-8 items-center-safe justify-center-safe'
}
/>
<div className="ml-auto flex size-8 items-center-safe justify-center-safe">
<Button size="icon" className="px-2" />
</div>
</CardHeader>
<CardFooter className="flex">
<div className="flex flex-1 flex-col items-start">
<h5>
{title}
{description && (
<>
{' '}
<InfoTooltip
content={
<div className="flex flex-col gap-2">
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
</div>
<div className="flex flex-col items-end gap-2 self-end text-sm"></div>
</CardFooter>
</Card>
</Skeleton>
))}
</div>
);
};
export default RecommendationsSkeleton;

View File

@@ -0,0 +1,30 @@
'use server';
import React from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { loadAnalyses } from '../_lib/server/load-analyses';
import { loadRecommendations } from '../_lib/server/load-recommendations';
import OrderAnalysesCards from './order-analyses-cards';
export default async function Recommendations({
account,
}: {
account: AccountWithParams;
}) {
const { analyses, countryCode } = await loadAnalyses();
const analysisRecommendations = await loadRecommendations(analyses, account);
const orderAnalyses = analyses.filter((analysis) =>
analysisRecommendations.includes(analysis.title),
);
if (orderAnalyses.length === 0) {
return null;
}
return (
<OrderAnalysesCards analyses={orderAnalyses} countryCode={countryCode} />
);
}

View File

@@ -1,18 +1,10 @@
'use server'; 'use server';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { updateLineItem } from '@lib/data/cart'; import { updateLineItem } from '@lib/data/cart';
import { StoreProductVariant } from '@medusajs/types'; import { StoreProductVariant } from '@medusajs/types';
import logRequestResult from '~/lib/services/audit.service';
import { handleAddToCart } from '~/lib/services/medusaCart.service'; import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { import { createInitialReservation } from '~/lib/services/reservation.service';
cancelReservation,
createInitialReservation,
} from '~/lib/services/reservation.service';
import { RequestStatus } from '~/lib/types/audit';
import { ConnectedOnlineMethodName } from '~/lib/types/connected-online';
import { ExternalApi } from '~/lib/types/external';
export async function createInitialReservationAction( export async function createInitialReservationAction(
selectedVariant: Pick<StoreProductVariant, 'id'>, selectedVariant: Pick<StoreProductVariant, 'id'>,
@@ -49,63 +41,3 @@ export async function createInitialReservationAction(
}); });
} }
} }
export async function cancelTtoBooking(
bookingCode: string,
clinicId: number,
medusaLineItemId: string,
) {
try {
await fetch(
`${process.env.CONNECTED_ONLINE_URL}/${ConnectedOnlineMethodName.ConfirmedCancel}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
param: `{'Value':'${bookingCode}|${clinicId}|et'}`,
}),
},
);
await cancelReservation(medusaLineItemId);
await logRequestResult(
ExternalApi.ConnectedOnline,
ConnectedOnlineMethodName.ConfirmedCancel,
RequestStatus.Success,
medusaLineItemId,
);
} catch (error) {
await logRequestResult(
ExternalApi.ConnectedOnline,
ConnectedOnlineMethodName.ConfirmedCancel,
RequestStatus.Fail,
JSON.stringify(error),
);
if (error instanceof Error) {
console.error('Error cancelling booking: ' + error.message);
}
console.error('Error cancelling booking: ', error);
}
}
export async function isPaymentRequiredForService(serviceId: number) {
const supabaseClient = getSupabaseServerClient();
try {
const { data } = await supabaseClient
.schema('medreport')
.from('connected_online_services')
.select('requires_payment')
.eq('id', serviceId)
.is('requires_payment', true)
.maybeSingle();
return !!data;
} catch (error) {
console.error('Error checking payment requirement: ', error);
return false;
}
}

Some files were not shown because too many files have changed in this diff Show More