Merge branch 'develop' of https://github.com/MR-medreport/MRB2B into MED-103
This commit is contained in:
@@ -1,20 +1,7 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import {
|
||||
renderAllResultsReceivedEmail,
|
||||
renderFirstResultsReceivedEmail,
|
||||
} from '@kit/email-templates';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import {
|
||||
getAssignedDoctorAccount,
|
||||
getDoctorAccounts,
|
||||
} from '../../../../../lib/services/account.service';
|
||||
import {
|
||||
NotificationAction,
|
||||
createNotificationLog,
|
||||
} from '../../../../../lib/services/audit/notificationEntries.service';
|
||||
import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service';
|
||||
import { RecordChange, Tables } from '../record-change.type';
|
||||
|
||||
export function createDatabaseWebhookRouterService(
|
||||
@@ -113,58 +100,13 @@ class DatabaseWebhookRouterService {
|
||||
return;
|
||||
}
|
||||
|
||||
let action;
|
||||
try {
|
||||
const data = {
|
||||
analysisOrderId: record.id,
|
||||
language: 'et',
|
||||
};
|
||||
const { createAnalysisOrderWebhooksService } = await import(
|
||||
'@kit/notifications/webhooks/analysis-order-notifications.service'
|
||||
);
|
||||
|
||||
if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') {
|
||||
action = NotificationAction.NEW_JOBS_ALERT;
|
||||
const service = createAnalysisOrderWebhooksService();
|
||||
|
||||
const doctorAccounts = await getDoctorAccounts();
|
||||
const doctorEmails: string[] = doctorAccounts
|
||||
.map(({ email }) => email)
|
||||
.filter((email): email is string => !!email);
|
||||
|
||||
await sendEmailFromTemplate(
|
||||
renderFirstResultsReceivedEmail,
|
||||
data,
|
||||
doctorEmails,
|
||||
);
|
||||
} else if (record.status === 'FULL_ANALYSIS_RESPONSE') {
|
||||
action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT;
|
||||
const doctorAccount = await getAssignedDoctorAccount(record.id);
|
||||
const assignedDoctorEmail = doctorAccount?.email;
|
||||
|
||||
if (!assignedDoctorEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmailFromTemplate(
|
||||
renderAllResultsReceivedEmail,
|
||||
data,
|
||||
assignedDoctorEmail,
|
||||
);
|
||||
}
|
||||
|
||||
if (action) {
|
||||
await createNotificationLog({
|
||||
action,
|
||||
status: 'SUCCESS',
|
||||
relatedRecordId: record.id,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (action)
|
||||
await createNotificationLog({
|
||||
action,
|
||||
status: 'FAIL',
|
||||
comment: e?.message,
|
||||
relatedRecordId: record.id,
|
||||
});
|
||||
}
|
||||
return service.handleStatusChangeWebhook(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,33 +49,28 @@ export async function renderAccountDeleteEmail(props: Props) {
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:hello`, {
|
||||
displayName: props.userDisplayName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph1`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph2`)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph3`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph4`, {
|
||||
productName: props.productName,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
render
|
||||
render,
|
||||
} from '@react-email/components';
|
||||
|
||||
import { BodyStyle } from '../components/body-style';
|
||||
@@ -46,11 +46,10 @@ export async function renderAllResultsReceivedEmail({
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:hello`)}
|
||||
</Text>
|
||||
@@ -62,7 +61,6 @@ export async function renderAllResultsReceivedEmail({
|
||||
>
|
||||
{t(`${namespace}:linkText`)}
|
||||
</EmailButton>
|
||||
|
||||
<Text>
|
||||
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||
|
||||
@@ -55,23 +55,19 @@ export async function renderCompanyOfferEmail({
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:companyName`)} {companyData.companyName}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:contactPerson`)} {companyData.contactPerson}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:email`)} {companyData.email}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:phone`)} {companyData.phone || 'N/A'}
|
||||
</Text>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
import { BodyStyle } from '../components/body-style';
|
||||
import CommonFooter from '../components/common-footer';
|
||||
import { EmailContent } from '../components/content';
|
||||
import { EmailButton } from '../components/email-button';
|
||||
import { EmailHeader } from '../components/header';
|
||||
import { EmailHeading } from '../components/heading';
|
||||
import { EmailWrapper } from '../components/wrapper';
|
||||
@@ -20,12 +20,10 @@ import { initializeEmailI18n } from '../lib/i18n';
|
||||
export async function renderDoctorSummaryReceivedEmail({
|
||||
language,
|
||||
recipientName,
|
||||
orderNr,
|
||||
analysisOrderId,
|
||||
}: {
|
||||
language?: string;
|
||||
language: string;
|
||||
recipientName: string;
|
||||
orderNr: string;
|
||||
analysisOrderId: number;
|
||||
}) {
|
||||
const namespace = 'doctor-summary-received-email';
|
||||
@@ -35,13 +33,9 @@ export async function renderDoctorSummaryReceivedEmail({
|
||||
namespace: [namespace, 'common'],
|
||||
});
|
||||
|
||||
const previewText = t(`${namespace}:previewText`, {
|
||||
orderNr,
|
||||
});
|
||||
const previewText = t(`${namespace}:previewText`);
|
||||
|
||||
const subject = t(`${namespace}:subject`, {
|
||||
orderNr,
|
||||
});
|
||||
const subject = t(`${namespace}:subject`);
|
||||
|
||||
const html = await render(
|
||||
<Html>
|
||||
@@ -54,29 +48,26 @@ export async function renderDoctorSummaryReceivedEmail({
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:hello`, {
|
||||
displayName: recipientName,
|
||||
})}
|
||||
</Text>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:summaryReceivedForOrder`, { orderNr })}
|
||||
{t(`common:helloName`, { name: recipientName })}
|
||||
</Text>
|
||||
|
||||
<EmailButton
|
||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
>
|
||||
{t(`${namespace}:linkText`, { orderNr })}
|
||||
</EmailButton>
|
||||
<Text>
|
||||
{t(`${namespace}:ifButtonDisabled`)}{' '}
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
{t(`${namespace}:p1`)}{' '}
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
>
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text>{t(`${namespace}:p2`)}</Text>
|
||||
<Text>{t(`${namespace}:p3`)}</Text>
|
||||
<Text>{t(`${namespace}:p4`)}</Text>
|
||||
|
||||
<CommonFooter t={t} />
|
||||
</EmailContent>
|
||||
</EmailWrapper>
|
||||
|
||||
@@ -46,11 +46,10 @@ export async function renderFirstResultsReceivedEmail({
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:hello`)}
|
||||
</Text>
|
||||
|
||||
@@ -74,20 +74,17 @@ export async function renderInviteEmail(props: Props) {
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{heading}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{heading}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{hello}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className="text-[16px] leading-[24px] text-[#242424]"
|
||||
dangerouslySetInnerHTML={{ __html: mainText }}
|
||||
/>
|
||||
|
||||
{props.teamLogo && (
|
||||
<Section>
|
||||
<Row>
|
||||
@@ -102,20 +99,16 @@ export async function renderInviteEmail(props: Props) {
|
||||
</Row>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section className="mb-[32px] mt-[32px] text-center">
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<CtaButton href={props.link}>{joinTeam}</CtaButton>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:copyPasteLink`)}{' '}
|
||||
<Link href={props.link} className="text-blue-600 no-underline">
|
||||
{props.link}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
|
||||
|
||||
<Text className="text-[12px] leading-[24px] text-[#666666]">
|
||||
{t(`${namespace}:invitationIntendedFor`, {
|
||||
invitedUserEmail: props.invitedUserEmail,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
render
|
||||
render,
|
||||
} from '@react-email/components';
|
||||
|
||||
import { BodyStyle } from '../components/body-style';
|
||||
@@ -50,11 +50,10 @@ export async function renderNewJobsAvailableEmail({
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:hello`)}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
render,
|
||||
} from '@react-email/components';
|
||||
|
||||
import { BodyStyle } from '../components/body-style';
|
||||
import CommonFooter from '../components/common-footer';
|
||||
import { EmailContent } from '../components/content';
|
||||
import { EmailHeader } from '../components/header';
|
||||
import { EmailHeading } from '../components/heading';
|
||||
import { EmailWrapper } from '../components/wrapper';
|
||||
import { initializeEmailI18n } from '../lib/i18n';
|
||||
|
||||
export async function renderOrderProcessingEmail({
|
||||
language,
|
||||
recipientName,
|
||||
partnerLocation,
|
||||
isUrine,
|
||||
}: {
|
||||
language: string;
|
||||
recipientName: string;
|
||||
partnerLocation: string;
|
||||
isUrine?: boolean;
|
||||
}) {
|
||||
const namespace = 'order-processing-email';
|
||||
|
||||
const { t } = await initializeEmailI18n({
|
||||
language,
|
||||
namespace: [namespace, 'common'],
|
||||
});
|
||||
|
||||
const previewText = t(`${namespace}:previewText`);
|
||||
|
||||
const subject = t(`${namespace}:subject`);
|
||||
|
||||
const p2 = t(`${namespace}:p2`);
|
||||
const p4 = t(`${namespace}:p4`);
|
||||
const p1Urine = t(`${namespace}:p1Urine`);
|
||||
|
||||
const html = await render(
|
||||
<Html>
|
||||
<Head>
|
||||
<BodyStyle />
|
||||
</Head>
|
||||
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`common:helloName`, { name: recipientName })}
|
||||
</Text>
|
||||
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||
{t(`${namespace}:heading`)}
|
||||
</Text>
|
||||
<Text>{t(`${namespace}:p1`, { partnerLocation })}</Text>
|
||||
<Text dangerouslySetInnerHTML={{ __html: p2 }}></Text>
|
||||
<Text>{t(`${namespace}:p3`)}</Text>
|
||||
<Text dangerouslySetInnerHTML={{ __html: p4 }}></Text>
|
||||
{isUrine && (
|
||||
<>
|
||||
<Text dangerouslySetInnerHTML={{ __html: p1Urine }}></Text>
|
||||
<Text>{t(`${namespace}:p2Urine`)}</Text>
|
||||
</>
|
||||
)}
|
||||
<Text>{t(`${namespace}:p5`)}</Text>
|
||||
<Text>{t(`${namespace}:p6`)}</Text>
|
||||
<CommonFooter t={t} />
|
||||
</EmailContent>
|
||||
</EmailWrapper>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>,
|
||||
);
|
||||
|
||||
return {
|
||||
html,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
@@ -60,18 +60,17 @@ export async function renderOtpEmail(props: Props) {
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{heading}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{heading}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] text-[#242424]">{mainText}</Text>
|
||||
|
||||
<Text className="text-[16px] text-[#242424]">{otpText}</Text>
|
||||
|
||||
<Section className="mb-[16px] mt-[16px] text-center">
|
||||
<Section className="mt-[16px] mb-[16px] text-center">
|
||||
<Button className={'w-full rounded bg-neutral-950 text-center'}>
|
||||
<Text className="text-[16px] font-semibold leading-[16px] text-white">
|
||||
<Text className="text-[16px] leading-[16px] font-semibold text-white">
|
||||
{props.otp}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
render,
|
||||
} from '@react-email/components';
|
||||
|
||||
import { BodyStyle } from '../components/body-style';
|
||||
import CommonFooter from '../components/common-footer';
|
||||
import { EmailContent } from '../components/content';
|
||||
import { EmailHeader } from '../components/header';
|
||||
import { EmailHeading } from '../components/heading';
|
||||
import { EmailWrapper } from '../components/wrapper';
|
||||
import { initializeEmailI18n } from '../lib/i18n';
|
||||
|
||||
export async function renderPatientFirstResultsReceivedEmail({
|
||||
language,
|
||||
recipientName,
|
||||
analysisOrderId,
|
||||
}: {
|
||||
language: string;
|
||||
recipientName: string;
|
||||
analysisOrderId: number;
|
||||
}) {
|
||||
const namespace = 'patient-first-results-received-email';
|
||||
|
||||
const { t } = await initializeEmailI18n({
|
||||
language,
|
||||
namespace: [namespace, 'common'],
|
||||
});
|
||||
|
||||
const previewText = t(`${namespace}:previewText`);
|
||||
|
||||
const subject = t(`${namespace}:subject`);
|
||||
|
||||
const html = await render(
|
||||
<Html>
|
||||
<Head>
|
||||
<BodyStyle />
|
||||
</Head>
|
||||
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`common:helloName`, { name: recipientName })}
|
||||
</Text>
|
||||
<Text>
|
||||
{t(`${namespace}:p1`)}{' '}
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
>
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text>{t(`${namespace}:p2`)}</Text>
|
||||
<Text>{t(`${namespace}:p3`)}</Text>
|
||||
<Text>{t(`${namespace}:p4`)}</Text>
|
||||
<CommonFooter t={t} />
|
||||
</EmailContent>
|
||||
</EmailWrapper>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>,
|
||||
);
|
||||
|
||||
return {
|
||||
html,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Body,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
render,
|
||||
} from '@react-email/components';
|
||||
|
||||
import { BodyStyle } from '../components/body-style';
|
||||
import CommonFooter from '../components/common-footer';
|
||||
import { EmailContent } from '../components/content';
|
||||
import { EmailHeader } from '../components/header';
|
||||
import { EmailHeading } from '../components/heading';
|
||||
import { EmailWrapper } from '../components/wrapper';
|
||||
import { initializeEmailI18n } from '../lib/i18n';
|
||||
|
||||
export async function renderPatientFullResultsReceivedEmail({
|
||||
language,
|
||||
recipientName,
|
||||
analysisOrderId,
|
||||
}: {
|
||||
language: string;
|
||||
recipientName: string;
|
||||
analysisOrderId: number;
|
||||
}) {
|
||||
const namespace = 'patient-full-results-received-email';
|
||||
|
||||
const { t } = await initializeEmailI18n({
|
||||
language,
|
||||
namespace: [namespace, 'common'],
|
||||
});
|
||||
|
||||
const previewText = t(`${namespace}:previewText`);
|
||||
|
||||
const subject = t(`${namespace}:subject`);
|
||||
|
||||
const html = await render(
|
||||
<Html>
|
||||
<Head>
|
||||
<BodyStyle />
|
||||
</Head>
|
||||
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{previewText}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`common:helloName`, { name: recipientName })}
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
{t(`${namespace}:p1`)}{' '}
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
>
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text>{t(`${namespace}:p2`)}</Text>
|
||||
<Text>{t(`${namespace}:p3`)}</Text>
|
||||
|
||||
<CommonFooter t={t} />
|
||||
</EmailContent>
|
||||
</EmailWrapper>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>,
|
||||
);
|
||||
|
||||
return {
|
||||
html,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
|
||||
const previewText = t(`${namespace}:previewText`, {
|
||||
analysisPackageName: props.analysisPackageName,
|
||||
});
|
||||
|
||||
|
||||
const subject = t(`${namespace}:subject`, {
|
||||
analysisPackageName: props.analysisPackageName,
|
||||
});
|
||||
@@ -70,15 +70,13 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
|
||||
<Tailwind>
|
||||
<Body>
|
||||
<EmailWrapper>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{heading}</EmailHeading>
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<EmailHeader>
|
||||
<EmailHeading>{heading}</EmailHeading>
|
||||
</EmailHeader>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{hello}
|
||||
</Text>
|
||||
|
||||
{lines.map((line, index) => (
|
||||
<Text
|
||||
key={index}
|
||||
@@ -86,7 +84,6 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
|
||||
dangerouslySetInnerHTML={{ __html: line }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<CommonFooter t={t} />
|
||||
</EmailContent>
|
||||
</EmailWrapper>
|
||||
|
||||
@@ -7,3 +7,6 @@ export * from './emails/doctor-summary-received.email';
|
||||
export * from './emails/new-jobs-available.email';
|
||||
export * from './emails/first-results-received.email';
|
||||
export * from './emails/all-results-received.email';
|
||||
export * from './emails/order-processing.email';
|
||||
export * from './emails/patient-first-results-received.email';
|
||||
export * from './emails/patient-full-results-received.email';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"subject": "Doctor feedback to order {{orderNr}} received",
|
||||
"previewText": "A doctor has submitted feedback on your analysis results.",
|
||||
"hello": "Hello {{displayName}},",
|
||||
"summaryReceivedForOrder": "A doctor has submitted feedback to your analysis results from order {{orderNr}}.",
|
||||
"linkText": "View summary",
|
||||
"ifButtonDisabled": "If clicking the button does not work, copy this link to your browser's url field:"
|
||||
}
|
||||
"subject": "Doctor's summary has arrived",
|
||||
"previewText": "The doctor has prepared a summary of the test results.",
|
||||
"p1": "The doctor's summary has arrived:",
|
||||
"p2": "It is recommended to have a comprehensive health check-up regularly, at least once a year, if you wish to maintain an active and fulfilling lifestyle.",
|
||||
"p3": "MedReport makes it easy, convenient, and fast to view health data in one place and order health check-ups.",
|
||||
"p4": "SYNLAB customer support phone: 17123"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"subject": "The referral has been sent to the laboratory. Please go to give samples.",
|
||||
"heading": "Thank you for your order!",
|
||||
"previewText": "The referral for tests has been sent to the laboratory.",
|
||||
"p1": "The referral for tests has been sent to the laboratory digitally. Please go to give samples: {{partnerLocation}}.",
|
||||
"p2": "If you are unable to go to the selected location to give samples, you may visit any other sampling point convenient for you - <a href='https://medreport.ee/et/verevotupunktid'>see locations and opening hours</a>.",
|
||||
"p3": "It is recommended to give samples preferably in the morning (before 12:00) and on an empty stomach without drinking or eating (you may drink water).",
|
||||
"p4": "At the sampling point, please choose in the queue system: under <strong>referrals</strong> select <strong>specialist referral</strong>.",
|
||||
"p5": "If you have any additional questions, please do not hesitate to contact us.",
|
||||
"p6": "SYNLAB customer support phone: 17123",
|
||||
"p1Urine": "The tests include a <strong>urine test</strong>. For the urine test, please collect the first morning urine.",
|
||||
"p2Urine": "You can buy a sample container at the pharmacy and bring the sample with you (procedure performed at home), or ask for one at the sampling point (procedure performed in the point’s restroom)."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"subject": "The first ordered test results have arrived",
|
||||
"previewText": "The first test results have arrived.",
|
||||
"p1": "The first test results have arrived:",
|
||||
"p2": "We will send the next notification once all test results have been received in the system.",
|
||||
"p3": "If you have any additional questions, please feel free to contact us.",
|
||||
"p4": "SYNLAB customer support phone: 17123"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"subject": "All ordered test results have arrived. Awaiting doctor's summary.",
|
||||
"previewText": "All test results have arrived.",
|
||||
"p1": "All test results have arrived:",
|
||||
"p2": "We will send the next notification once the doctor's summary has been prepared.",
|
||||
"p3": "SYNLAB customer support phone: 17123"
|
||||
}
|
||||
@@ -4,5 +4,7 @@
|
||||
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
|
||||
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
|
||||
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
|
||||
}
|
||||
},
|
||||
"helloName": "Tere, {{name}}",
|
||||
"hello": "Tere"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"subject": "Saabus arsti kokkuvõtte tellimusele {{orderNr}}",
|
||||
"previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.",
|
||||
"hello": "Tere, {{displayName}}",
|
||||
"summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.",
|
||||
"linkText": "Vaata kokkuvõtet",
|
||||
"ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:"
|
||||
"subject": "Arsti kokkuvõte on saabunud",
|
||||
"previewText": "Arst on koostanud kokkuvõte analüüsitulemustele.",
|
||||
"p1": "Arsti kokkuvõte on saabunud:",
|
||||
"p2": "Põhjalikul terviseuuringul on soovituslik käia regulaarselt, aga vähemalt üks kord aastas, kui soovite säilitada aktiivset ja täisväärtuslikku elustiili.",
|
||||
"p3": "MedReport aitab lihtsalt, mugavalt ja kiirelt terviseandmeid ühest kohast vaadata ning tellida terviseuuringuid.",
|
||||
"p4": "SYNLAB klienditoe telefon: 17123"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"subject": "Saatekiri on saadetud laborisse. Palun mine proove andma.",
|
||||
"heading": "Täname tellimuse eest!",
|
||||
"previewText": "Saatekiri uuringute tegemiseks on saadetud laborisse.",
|
||||
"p1": "Saatekiri uuringute tegemiseks on saadetud laborisse digitaalselt. Palun mine proove andma: {{partnerLocation}}.",
|
||||
"p2": "Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - <a href='https://medreport.ee/et/verevotupunktid'>vaata asukohti ja lahtiolekuaegasid</a>.",
|
||||
"p3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).",
|
||||
"p4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>",
|
||||
"p5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
|
||||
"p6": "SYNLAB klienditoe telefon: 17123",
|
||||
"p1Urine": "Analüüsides on ette nähtud <strong>uriinianalüüs</strong>. Uriinianalüüsiks võta hommikune esmane uriin.",
|
||||
"p2Urine": "Proovitopsi võib soetada apteegist ja analüüsi kaasa võtta (teostada protseduur kodus) või küsida proovivõtupunktist (teostada protseduur proovipunkti wc-s)."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"subject": "Saabusid tellitud uuringute esimesed tulemused",
|
||||
"previewText": "Esimesed uuringute tulemused on saabunud.",
|
||||
"p1": "Esimesed uuringute tulemused on saabunud:",
|
||||
"p2": "Saadame järgmise teavituse, kui kõik uuringute vastused on saabunud süsteemi.",
|
||||
"p3": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
|
||||
"p4": "SYNLAB klienditoe telefon: 17123"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"subject": "Kõikide tellitud uuringute tulemused on saabunud. Ootab arsti kokkuvõtet.",
|
||||
"previewText": "Kõikide uuringute tulemused on saabunud.",
|
||||
"p1": "Kõikide uuringute tulemused on saabunud:",
|
||||
"p2": "Saadame järgmise teavituse kui arsti kokkuvõte on koostatud.",
|
||||
"p3": "SYNLAB klienditoe telefon: 17123"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"subject": "Получено заключение врача по заказу {{orderNr}}",
|
||||
"previewText": "Врач отправил заключение по вашим результатам анализа.",
|
||||
"hello": "Здравствуйте, {{displayName}}",
|
||||
"summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.",
|
||||
"linkText": "Посмотреть заключение",
|
||||
"ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:"
|
||||
"subject": "Заключение врача готово",
|
||||
"previewText": "Врач подготовил заключение по результатам анализов.",
|
||||
"p1": "Заключение врача готово:",
|
||||
"p2": "Рекомендуется проходить комплексное обследование регулярно, но как минимум один раз в год, если вы хотите сохранить активный и полноценный образ жизни.",
|
||||
"p3": "MedReport позволяет легко, удобно и быстро просматривать медицинские данные в одном месте и заказывать обследования.",
|
||||
"p4": "Телефон службы поддержки SYNLAB: 17123"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"subject": "Направление отправлено в лабораторию. Пожалуйста, сдайте анализы.",
|
||||
"heading": "Спасибо за заказ!",
|
||||
"previewText": "Направление на обследование отправлено в лабораторию.",
|
||||
"p1": "Направление на обследование было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: {{partnerLocation}}.",
|
||||
"p2": "Если у вас нет возможности прийти в выбранный пункт сдачи анализов, вы можете обратиться в любой удобный для вас пункт – <a href='https://medreport.ee/et/verevotupunktid'>посмотреть адреса и часы работы</a>.",
|
||||
"p3": "Рекомендуется сдавать анализы утром (до 12:00) натощак, без еды и напитков (разрешается пить воду).",
|
||||
"p4": "В пункте сдачи анализов выберите в системе очереди: в разделе <strong>направления</strong> → <strong>направление от специалиста</strong>.",
|
||||
"p5": "Если у вас возникли дополнительные вопросы, пожалуйста, свяжитесь с нами.",
|
||||
"p6": "Телефон службы поддержки SYNLAB: 17123",
|
||||
"p1Urine": "В обследование входит <strong>анализ мочи</strong>. Для анализа необходимо собрать первую утреннюю мочу.",
|
||||
"p2Urine": "Контейнер можно приобрести в аптеке и принести образец с собой (процедура проводится дома) или взять контейнер в пункте сдачи (процедура проводится в туалете пункта)."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"subject": "Поступили первые результаты заказанных исследований",
|
||||
"previewText": "Первые результаты исследований поступили.",
|
||||
"p1": "Первые результаты исследований поступили:",
|
||||
"p2": "Мы отправим следующее уведомление, когда все результаты исследований будут получены в системе.",
|
||||
"p3": "Если у вас возникнут дополнительные вопросы, пожалуйста, свяжитесь с нами.",
|
||||
"p4": "Телефон службы поддержки SYNLAB: 17123"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"subject": "Все заказанные результаты исследований поступили. Ожидается заключение врача.",
|
||||
"previewText": "Все результаты исследований поступили.",
|
||||
"p1": "Все результаты исследований поступили:",
|
||||
"p2": "Мы отправим следующее уведомление, когда заключение врача будет подготовлено.",
|
||||
"p3": "Телефон службы поддержки SYNLAB: 17123"
|
||||
}
|
||||
@@ -79,15 +79,9 @@ export function PersonalAccountDropdown({
|
||||
}) {
|
||||
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||
|
||||
const signedInAsLabel = useMemo(() => {
|
||||
const email = user?.email ?? undefined;
|
||||
const phone = user?.phone ?? undefined;
|
||||
|
||||
return email ?? phone;
|
||||
}, [user]);
|
||||
|
||||
const displayName =
|
||||
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
|
||||
const { name, last_name } = personalAccountData ?? {};
|
||||
const firstNameLabel = toTitleCase(name) ?? '-';
|
||||
const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
|
||||
|
||||
const hasTotpFactor = useMemo(() => {
|
||||
const factors = user?.factors ?? [];
|
||||
@@ -128,7 +122,7 @@ export function PersonalAccountDropdown({
|
||||
<ProfileAvatar
|
||||
className={'rounded-md'}
|
||||
fallbackClassName={'rounded-md border'}
|
||||
displayName={displayName ?? user?.email ?? ''}
|
||||
displayName={firstNameLabel}
|
||||
pictureUrl={personalAccountData?.picture_url}
|
||||
/>
|
||||
|
||||
@@ -142,7 +136,7 @@ export function PersonalAccountDropdown({
|
||||
data-test={'account-dropdown-display-name'}
|
||||
className={'truncate text-sm'}
|
||||
>
|
||||
{toTitleCase(displayName)}
|
||||
{firstNameLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={'block truncate'}>{signedInAsLabel}</span>
|
||||
<span className={'block truncate'}>{fullNameLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -265,11 +265,13 @@ function FactorQrCode({
|
||||
z.object({
|
||||
factorName: z.string().min(1),
|
||||
qrCode: z.string().min(1),
|
||||
totpSecret: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
factorName: '',
|
||||
qrCode: '',
|
||||
totpSecret: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -319,6 +321,7 @@ function FactorQrCode({
|
||||
if (data.type === 'totp') {
|
||||
form.setValue('factorName', name);
|
||||
form.setValue('qrCode', data.totp.qr_code);
|
||||
form.setValue('totpSecret', data.totp.secret);
|
||||
}
|
||||
|
||||
// dispatch event to set factor ID
|
||||
@@ -331,7 +334,7 @@ function FactorQrCode({
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'dark:bg-secondary flex flex-col space-y-4 rounded-lg border p-4'
|
||||
'dark:bg-secondary flex flex-col space-y-2 rounded-lg border p-4'
|
||||
}
|
||||
>
|
||||
<p>
|
||||
@@ -343,6 +346,10 @@ function FactorQrCode({
|
||||
<div className={'flex justify-center'}>
|
||||
<QrImage src={form.getValues('qrCode')} />
|
||||
</div>
|
||||
|
||||
<p className='text-center text-sm'>
|
||||
{form.getValues('totpSecret')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
|
||||
import PersonalCode from '~/lib/utils';
|
||||
|
||||
export type AccountWithParams =
|
||||
Database['medreport']['Tables']['accounts']['Row'] & {
|
||||
@@ -48,6 +49,33 @@ class AccountsApi {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getPersonalAccountByUserId
|
||||
* @description Get the personal account data for the given user ID.
|
||||
* @param userId
|
||||
*/
|
||||
async getPersonalAccountByUserId(userId: string): Promise<AccountWithParams> {
|
||||
const { data, error } = await this.client
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.select(
|
||||
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
|
||||
)
|
||||
.eq('primary_owner_user_id', userId)
|
||||
.eq('is_personal_account', true)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { personal_code, ...rest } = data;
|
||||
return {
|
||||
...rest,
|
||||
personal_code: PersonalCode.getPersonalCode(personal_code),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getAccountWorkspace
|
||||
* @description Get the account workspace data.
|
||||
|
||||
@@ -10,7 +10,7 @@ const personalCodeSchema = z.string().refine(
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'Invalid personal code',
|
||||
message: 'common:formFieldError.invalidPersonalCode',
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ export function AuthLayoutShell({
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'sm:py-auto flex flex-col items-center justify-center py-6' +
|
||||
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' +
|
||||
' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000'
|
||||
'sm:py-auto flex flex-col items-center justify-center py-6 h-screen' +
|
||||
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8'
|
||||
}
|
||||
>
|
||||
{Logo ? <Logo /> : null}
|
||||
|
||||
@@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button';
|
||||
* @see https://supabase.com/docs/guides/auth/social-login
|
||||
*/
|
||||
const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
|
||||
azure: 'email',
|
||||
keycloak: 'openid',
|
||||
// azure: 'email',
|
||||
// keycloak: 'openid',
|
||||
// add your OAuth providers here
|
||||
};
|
||||
|
||||
@@ -88,10 +88,12 @@ export const OauthProviders: React.FC<{
|
||||
queryParams.set('invite_token', props.inviteToken);
|
||||
}
|
||||
|
||||
const redirectPath = [
|
||||
props.paths.callback,
|
||||
queryParams.toString(),
|
||||
].join('?');
|
||||
// signicat/keycloak will not allow redirect-uri with changing query params
|
||||
const INCLUDE_QUERY_PARAMS = false as boolean;
|
||||
|
||||
const redirectPath = INCLUDE_QUERY_PARAMS
|
||||
? [props.paths.callback, queryParams.toString()].join('?')
|
||||
: props.paths.callback;
|
||||
|
||||
const redirectTo = [origin, redirectPath].join('');
|
||||
const scopes = OAUTH_SCOPES[provider] ?? undefined;
|
||||
@@ -102,6 +104,7 @@ export const OauthProviders: React.FC<{
|
||||
redirectTo,
|
||||
queryParams: props.queryParams,
|
||||
scopes,
|
||||
// skipBrowserRedirect: false,
|
||||
},
|
||||
} satisfies SignInWithOAuthCredentials;
|
||||
|
||||
@@ -110,12 +113,16 @@ export const OauthProviders: React.FC<{
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'auth:signInWithProvider'}
|
||||
values={{
|
||||
provider: getProviderName(provider),
|
||||
}}
|
||||
/>
|
||||
{provider === 'keycloak' ? (
|
||||
<Trans i18nKey={'auth:signInWithKeycloak'} />
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey={'auth:signInWithProvider'}
|
||||
values={{
|
||||
provider: getProviderName(provider),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AuthProviderButton>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,9 +10,18 @@ import { useCaptchaToken } from '../captcha/client';
|
||||
import { usePasswordSignUpFlow } from '../hooks/use-sign-up-flow';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignUpForm } from './password-sign-up-form';
|
||||
import { Spinner } from '@kit/ui/makerkit/spinner';
|
||||
|
||||
interface EmailPasswordSignUpContainerProps {
|
||||
displayTermsCheckbox?: boolean;
|
||||
authConfig: {
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: string[];
|
||||
};
|
||||
displayTermsCheckbox: boolean | undefined;
|
||||
isMailerAutoconfirmEnabled: boolean;
|
||||
};
|
||||
defaultValues?: {
|
||||
email: string;
|
||||
};
|
||||
@@ -21,10 +30,10 @@ interface EmailPasswordSignUpContainerProps {
|
||||
}
|
||||
|
||||
export function EmailPasswordSignUpContainer({
|
||||
authConfig,
|
||||
defaultValues,
|
||||
onSignUp,
|
||||
emailRedirectTo,
|
||||
displayTermsCheckbox,
|
||||
}: EmailPasswordSignUpContainerProps) {
|
||||
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
|
||||
|
||||
@@ -43,7 +52,12 @@ export function EmailPasswordSignUpContainer({
|
||||
return (
|
||||
<>
|
||||
<If condition={showVerifyEmailAlert}>
|
||||
<SuccessAlert />
|
||||
{authConfig.isMailerAutoconfirmEnabled ? (
|
||||
<div className="flex justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : <SuccessAlert />
|
||||
}
|
||||
</If>
|
||||
|
||||
<If condition={!showVerifyEmailAlert}>
|
||||
@@ -53,7 +67,7 @@ export function EmailPasswordSignUpContainer({
|
||||
onSubmit={onSignupRequested}
|
||||
loading={loading}
|
||||
defaultValues={defaultValues}
|
||||
displayTermsCheckbox={displayTermsCheckbox}
|
||||
displayTermsCheckbox={authConfig.displayTermsCheckbox}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,12 @@ import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { PasswordSignInContainer } from './password-sign-in-container';
|
||||
|
||||
export type Providers = {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
|
||||
export function SignInMethodsContainer(props: {
|
||||
inviteToken?: string;
|
||||
|
||||
@@ -25,11 +31,7 @@ export function SignInMethodsContainer(props: {
|
||||
updateAccount: string;
|
||||
};
|
||||
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
providers: Providers;
|
||||
}) {
|
||||
const client = useSupabase();
|
||||
const router = useRouter();
|
||||
@@ -108,6 +110,9 @@ export function SignInMethodsContainer(props: {
|
||||
callback: props.paths.callback,
|
||||
returnPath: props.paths.returnPath,
|
||||
}}
|
||||
queryParams={{
|
||||
prompt: 'login',
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -21,15 +20,20 @@ export function SignUpMethodsContainer(props: {
|
||||
updateAccount: string;
|
||||
};
|
||||
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: Provider[];
|
||||
authConfig: {
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
displayTermsCheckbox: boolean | undefined;
|
||||
isMailerAutoconfirmEnabled: boolean;
|
||||
};
|
||||
|
||||
displayTermsCheckbox?: boolean;
|
||||
inviteToken?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const redirectUrl = getCallbackUrl(props);
|
||||
const defaultValues = getDefaultValues();
|
||||
|
||||
@@ -39,26 +43,33 @@ export function SignUpMethodsContainer(props: {
|
||||
<InviteAlert />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.password}>
|
||||
<If condition={props.authConfig.providers.password}>
|
||||
<EmailPasswordSignUpContainer
|
||||
emailRedirectTo={props.paths.callback}
|
||||
defaultValues={defaultValues}
|
||||
displayTermsCheckbox={props.displayTermsCheckbox}
|
||||
onSignUp={() => redirect(redirectUrl)}
|
||||
authConfig={props.authConfig}
|
||||
onSignUp={() => {
|
||||
if (!props.authConfig.isMailerAutoconfirmEnabled) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
router.replace(props.paths.updateAccount)
|
||||
}, 2_500);
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<If condition={props.authConfig.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteToken={props.inviteToken}
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={true}
|
||||
defaultValues={defaultValues}
|
||||
displayTermsCheckbox={props.displayTermsCheckbox}
|
||||
displayTermsCheckbox={props.authConfig.displayTermsCheckbox}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
<If condition={props.authConfig.providers.oAuth.length}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
@@ -72,13 +83,16 @@ export function SignUpMethodsContainer(props: {
|
||||
</div>
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
enabledProviders={props.authConfig.providers.oAuth}
|
||||
inviteToken={props.inviteToken}
|
||||
shouldCreateUser={true}
|
||||
paths={{
|
||||
callback: props.paths.callback,
|
||||
returnPath: props.paths.appHome,
|
||||
}}
|
||||
queryParams={{
|
||||
prompt: 'login',
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
|
||||
@@ -9,8 +9,8 @@ export interface AccountSubmitData {
|
||||
email: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
weight: number | null;
|
||||
height: number | null;
|
||||
weight?: number | null | undefined;
|
||||
height?: number | null | undefined;
|
||||
userConsent: boolean;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ class AuthApi {
|
||||
p_name: data.firstName,
|
||||
p_last_name: data.lastName,
|
||||
p_personal_code: data.personalCode,
|
||||
p_email: data.email || '',
|
||||
p_phone: data.phone || '',
|
||||
p_city: data.city || '',
|
||||
p_has_consent_personal_data: data.userConsent,
|
||||
|
||||
@@ -126,7 +126,7 @@ export const giveFeedbackAction = doctorAction(
|
||||
|
||||
if (isCompleted) {
|
||||
await createNotificationLog({
|
||||
action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED,
|
||||
action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED,
|
||||
status: 'SUCCESS',
|
||||
relatedRecordId: analysisOrderId,
|
||||
});
|
||||
@@ -136,7 +136,7 @@ export const giveFeedbackAction = doctorAction(
|
||||
} catch (e: any) {
|
||||
if (isCompleted) {
|
||||
await createNotificationLog({
|
||||
action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED,
|
||||
action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED,
|
||||
status: 'FAIL',
|
||||
comment: e?.message,
|
||||
relatedRecordId: analysisOrderId,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import z from 'zod/v3';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import z from 'zod';
|
||||
|
||||
export const doctorJobSelectSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
userId: z.uuid(),
|
||||
analysisOrderId: z.number(),
|
||||
});
|
||||
export type DoctorJobSelect = z.infer<typeof doctorJobSelectSchema>;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import "server-only"
|
||||
|
||||
import { cookies as nextCookies } from "next/headers"
|
||||
|
||||
const CookieName = {
|
||||
MEDUSA_CUSTOMER_ID: "_medusa_customer_id",
|
||||
MEDUSA_JWT: "_medusa_jwt",
|
||||
MEDUSA_CART_ID: "_medusa_cart_id",
|
||||
MEDUSA_CACHE_ID: "_medusa_cache_id",
|
||||
}
|
||||
|
||||
export const getAuthHeaders = async (): Promise<
|
||||
{ authorization: string } | {}
|
||||
> => {
|
||||
try {
|
||||
const cookies = await nextCookies()
|
||||
const token = cookies.get("_medusa_jwt")?.value
|
||||
const token = cookies.get(CookieName.MEDUSA_JWT)?.value
|
||||
|
||||
if (!token) {
|
||||
return {}
|
||||
@@ -23,7 +31,7 @@ export const getMedusaCustomerId = async (): Promise<
|
||||
> => {
|
||||
try {
|
||||
const cookies = await nextCookies()
|
||||
const customerId = cookies.get("_medusa_customer_id")?.value
|
||||
const customerId = cookies.get(CookieName.MEDUSA_CUSTOMER_ID)?.value
|
||||
|
||||
if (!customerId) {
|
||||
return { customerId: null }
|
||||
@@ -31,14 +39,14 @@ export const getMedusaCustomerId = async (): Promise<
|
||||
|
||||
return { customerId }
|
||||
} catch {
|
||||
return { customerId: null}
|
||||
return { customerId: null }
|
||||
}
|
||||
}
|
||||
|
||||
export const getCacheTag = async (tag: string): Promise<string> => {
|
||||
try {
|
||||
const cookies = await nextCookies()
|
||||
const cacheId = cookies.get("_medusa_cache_id")?.value
|
||||
const cacheId = cookies.get(CookieName.MEDUSA_CACHE_ID)?.value
|
||||
|
||||
if (!cacheId) {
|
||||
return ""
|
||||
@@ -66,51 +74,51 @@ export const getCacheOptions = async (
|
||||
return { tags: [`${cacheTag}`] }
|
||||
}
|
||||
|
||||
const getCookieSharedOptions = () => ({
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
});
|
||||
const getCookieResetOptions = () => ({
|
||||
maxAge: -1,
|
||||
});
|
||||
|
||||
export const setAuthToken = async (token: string) => {
|
||||
const cookies = await nextCookies()
|
||||
cookies.set("_medusa_jwt", token, {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
cookies.set(CookieName.MEDUSA_JWT, token, {
|
||||
...getCookieSharedOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
export const setMedusaCustomerId = async (customerId: string) => {
|
||||
const cookies = await nextCookies()
|
||||
cookies.set("_medusa_customer_id", customerId, {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
cookies.set(CookieName.MEDUSA_CUSTOMER_ID, customerId, {
|
||||
...getCookieSharedOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
export const removeAuthToken = async () => {
|
||||
const cookies = await nextCookies()
|
||||
cookies.set("_medusa_jwt", "", {
|
||||
maxAge: -1,
|
||||
cookies.set(CookieName.MEDUSA_JWT, "", {
|
||||
...getCookieResetOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
export const getCartId = async () => {
|
||||
const cookies = await nextCookies()
|
||||
return cookies.get("_medusa_cart_id")?.value
|
||||
return cookies.get(CookieName.MEDUSA_CART_ID)?.value
|
||||
}
|
||||
|
||||
export const setCartId = async (cartId: string) => {
|
||||
const cookies = await nextCookies()
|
||||
cookies.set("_medusa_cart_id", cartId, {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
cookies.set(CookieName.MEDUSA_CART_ID, cartId, {
|
||||
...getCookieSharedOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
export const removeCartId = async () => {
|
||||
const cookies = await nextCookies()
|
||||
cookies.set("_medusa_cart_id", "", {
|
||||
maxAge: -1,
|
||||
cookies.set(CookieName.MEDUSA_CART_ID, "", {
|
||||
...getCookieResetOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { sdk } from "@lib/config"
|
||||
import medusaError from "@lib/util/medusa-error"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { revalidateTag } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
import {
|
||||
getAuthHeaders,
|
||||
getCacheOptions,
|
||||
@@ -127,21 +126,21 @@ export async function login(_currentState: unknown, formData: FormData) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function signout(countryCode?: string, shouldRedirect = true) {
|
||||
export async function medusaLogout(countryCode = 'ee', canRevalidateTags = true) {
|
||||
await sdk.auth.logout()
|
||||
|
||||
await removeAuthToken()
|
||||
|
||||
const customerCacheTag = await getCacheTag("customers")
|
||||
revalidateTag(customerCacheTag)
|
||||
if (canRevalidateTags) {
|
||||
const customerCacheTag = await getCacheTag("customers")
|
||||
revalidateTag(customerCacheTag)
|
||||
}
|
||||
|
||||
await removeCartId()
|
||||
|
||||
const cartCacheTag = await getCacheTag("carts")
|
||||
revalidateTag(cartCacheTag)
|
||||
|
||||
if (shouldRedirect) {
|
||||
redirect(`/${countryCode!}/account`)
|
||||
if (canRevalidateTags) {
|
||||
const cartCacheTag = await getCacheTag("carts")
|
||||
revalidateTag(cartCacheTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,72 +261,110 @@ export const updateCustomerAddress = async (
|
||||
})
|
||||
}
|
||||
|
||||
export async function medusaLoginOrRegister(credentials: {
|
||||
email: string
|
||||
password?: string
|
||||
}) {
|
||||
const { email, password } = credentials;
|
||||
async function medusaLogin(email: string, password: string) {
|
||||
const token = await sdk.auth.login("customer", "emailpass", { email, password });
|
||||
await setAuthToken(token as string);
|
||||
|
||||
try {
|
||||
const token = await sdk.auth.login("customer", "emailpass", {
|
||||
email,
|
||||
password,
|
||||
await transferCart();
|
||||
} catch (e) {
|
||||
console.error("Failed to transfer cart", e);
|
||||
}
|
||||
|
||||
const customer = await retrieveCustomer();
|
||||
if (!customer) {
|
||||
throw new Error("Customer not found for active session");
|
||||
}
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async function medusaRegister({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
lastName,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string | undefined;
|
||||
lastName: string | undefined;
|
||||
}) {
|
||||
console.info(`Creating new Medusa account for Keycloak user with email=${email}`);
|
||||
|
||||
const registerToken = await sdk.auth.register("customer", "emailpass", { email, password });
|
||||
await setAuthToken(registerToken);
|
||||
|
||||
console.info(`Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`);
|
||||
await sdk.store.customer.create(
|
||||
{ email, first_name: name, last_name: lastName },
|
||||
{},
|
||||
{
|
||||
...(await getAuthHeaders()),
|
||||
});
|
||||
await setAuthToken(token as string);
|
||||
}
|
||||
|
||||
try {
|
||||
await transferCart();
|
||||
} catch (e) {
|
||||
console.error("Failed to transfer cart", e);
|
||||
export async function medusaLoginOrRegister(credentials: {
|
||||
email: string
|
||||
supabaseUserId?: string
|
||||
name?: string,
|
||||
lastName?: string,
|
||||
} & ({ isDevPasswordLogin: true; password: string } | { isDevPasswordLogin?: false; password?: undefined })) {
|
||||
const { email, supabaseUserId, name, lastName } = credentials;
|
||||
|
||||
|
||||
const password = await (async () => {
|
||||
if (credentials.isDevPasswordLogin) {
|
||||
return credentials.password;
|
||||
}
|
||||
|
||||
const customerCacheTag = await getCacheTag("customers");
|
||||
revalidateTag(customerCacheTag);
|
||||
return generateDeterministicPassword(email, supabaseUserId);
|
||||
})();
|
||||
|
||||
try {
|
||||
return await medusaLogin(email, password);
|
||||
} catch (loginError) {
|
||||
console.error("Failed to login customer, attempting to register", loginError);
|
||||
|
||||
const customer = await retrieveCustomer();
|
||||
if (!customer) {
|
||||
throw new Error("Customer not found");
|
||||
}
|
||||
return customer.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to login customer, attempting to register", error);
|
||||
try {
|
||||
const registerToken = await sdk.auth.register("customer", "emailpass", {
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
await setAuthToken(registerToken as string);
|
||||
|
||||
const headers = {
|
||||
...(await getAuthHeaders()),
|
||||
};
|
||||
|
||||
await sdk.store.customer.create({ email }, {}, headers);
|
||||
|
||||
const loginToken = await sdk.auth.login("customer", "emailpass", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
await setAuthToken(loginToken as string);
|
||||
|
||||
const customerCacheTag = await getCacheTag("customers");
|
||||
revalidateTag(customerCacheTag);
|
||||
|
||||
try {
|
||||
await transferCart();
|
||||
} catch (e) {
|
||||
console.error("Failed to transfer cart", e);
|
||||
}
|
||||
|
||||
const customer = await retrieveCustomer();
|
||||
if (!customer) {
|
||||
throw new Error("Customer not found");
|
||||
}
|
||||
return customer.id;
|
||||
await medusaRegister({ email, password, name, lastName });
|
||||
return await medusaLogin(email, password);
|
||||
} catch (registerError) {
|
||||
console.error("Failed to create Medusa account for user with email=${email}", registerError);
|
||||
throw medusaError(registerError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic password based on user identifier
|
||||
* This ensures the same user always gets the same password for Medusa
|
||||
*/
|
||||
async function generateDeterministicPassword(email: string, userId?: string): Promise<string> {
|
||||
// Use the user ID or email as the base for deterministic generation
|
||||
const baseString = userId || email;
|
||||
const secret = process.env.MEDUSA_PASSWORD_SECRET!;
|
||||
|
||||
// Create a deterministic password using HMAC
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const messageData = encoder.encode(baseString);
|
||||
|
||||
// Import key for HMAC
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
// Generate HMAC
|
||||
const signature = await crypto.subtle.sign('HMAC', key, messageData);
|
||||
// Convert to base64 and make it a valid password
|
||||
const hashArray = Array.from(new Uint8Array(signature));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
// Take first 24 characters and add some complexity
|
||||
const basePassword = hashHex.substring(0, 24);
|
||||
// Add some required complexity for Medusa (uppercase, lowercase, numbers, symbols)
|
||||
return `Mk${basePassword}9!`;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ export const listOrders = async (
|
||||
},
|
||||
headers,
|
||||
next,
|
||||
cache: "force-cache",
|
||||
})
|
||||
.then(({ orders }) => orders)
|
||||
.catch((err) => medusaError(err))
|
||||
|
||||
@@ -14,7 +14,12 @@ export const listProducts = async ({
|
||||
regionId,
|
||||
}: {
|
||||
pageParam?: number
|
||||
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[], category_id?: string }
|
||||
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & {
|
||||
"type_id[0]"?: string;
|
||||
id?: string[],
|
||||
category_id?: string;
|
||||
order?: 'title';
|
||||
}
|
||||
countryCode?: string
|
||||
regionId?: string
|
||||
}): Promise<{
|
||||
@@ -68,7 +73,6 @@ export const listProducts = async ({
|
||||
},
|
||||
headers,
|
||||
next,
|
||||
cache: "force-cache",
|
||||
}
|
||||
)
|
||||
.then(({ products, count }) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import MapPin from "@modules/common/icons/map-pin"
|
||||
import Package from "@modules/common/icons/package"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { signout } from "@lib/data/customer"
|
||||
import { medusaLogout } from "@lib/data/customer"
|
||||
|
||||
const AccountNav = ({
|
||||
customer,
|
||||
@@ -21,7 +21,7 @@ const AccountNav = ({
|
||||
const { countryCode } = useParams() as { countryCode: string }
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signout(countryCode)
|
||||
await medusaLogout(countryCode)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./hooks": "./src/hooks/index.ts"
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./webhooks/*": "./src/server/services/webhooks/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
renderAllResultsReceivedEmail,
|
||||
renderFirstResultsReceivedEmail,
|
||||
renderOrderProcessingEmail,
|
||||
renderPatientFirstResultsReceivedEmail,
|
||||
renderPatientFullResultsReceivedEmail,
|
||||
} from '@kit/email-templates';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getFullName } from '@kit/shared/utils';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
import {
|
||||
getAssignedDoctorAccount,
|
||||
getDoctorAccounts,
|
||||
getUserContactAdmin,
|
||||
} from '~/lib/services/account.service';
|
||||
import {
|
||||
NotificationAction,
|
||||
createNotificationLog,
|
||||
} from '~/lib/services/audit/notificationEntries.service';
|
||||
import {
|
||||
EmailRenderer,
|
||||
sendEmailFromTemplate,
|
||||
} from '~/lib/services/mailer.service';
|
||||
|
||||
type AnalysisOrder = Database['medreport']['Tables']['analysis_orders']['Row'];
|
||||
|
||||
export function createAnalysisOrderWebhooksService() {
|
||||
return new AnalysisOrderWebhooksService();
|
||||
}
|
||||
|
||||
class AnalysisOrderWebhooksService {
|
||||
private readonly namespace = 'analysis_orders.webhooks';
|
||||
|
||||
async handleStatusChangeWebhook(analysisOrder: AnalysisOrder) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
analysisOrderId: analysisOrder.id,
|
||||
namespace: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Received status change update. Processing...');
|
||||
let actions: NotificationAction[] = [];
|
||||
try {
|
||||
if (analysisOrder.status === 'PROCESSING') {
|
||||
actions = [NotificationAction.PATIENT_ORDER_PROCESSING];
|
||||
await this.sendProcessingNotification(analysisOrder);
|
||||
}
|
||||
|
||||
if (analysisOrder.status === 'PARTIAL_ANALYSIS_RESPONSE') {
|
||||
actions = [
|
||||
NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED,
|
||||
NotificationAction.DOCTOR_NEW_JOBS,
|
||||
];
|
||||
|
||||
await this.sendPartialAnalysisResultsNotifications(analysisOrder);
|
||||
}
|
||||
|
||||
if (analysisOrder.status === 'FULL_ANALYSIS_RESPONSE') {
|
||||
actions = [
|
||||
NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED,
|
||||
NotificationAction.PATIENT_FULL_RESULTS_RECEIVED,
|
||||
];
|
||||
await this.sendFullAnalysisResultsNotifications(analysisOrder);
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
return logger.info(ctx, 'Status change notifications sent.');
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Status change processed. No notifications to send.');
|
||||
} catch (e: any) {
|
||||
if (actions.length)
|
||||
await Promise.all(
|
||||
actions.map((action) =>
|
||||
createNotificationLog({
|
||||
action,
|
||||
status: 'FAIL',
|
||||
comment: e?.message,
|
||||
relatedRecordId: analysisOrder.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
logger.error(
|
||||
ctx,
|
||||
`Error while processing status change: ${JSON.stringify(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async sendProcessingNotification(analysisOrder: AnalysisOrder) {
|
||||
const logger = await getLogger();
|
||||
const supabase = getSupabaseServerAdminClient();
|
||||
|
||||
const userContact = await getUserContactAdmin(analysisOrder.user_id);
|
||||
|
||||
if (!userContact?.email) {
|
||||
await createNotificationLog({
|
||||
action: NotificationAction.PATIENT_ORDER_PROCESSING,
|
||||
status: 'FAIL',
|
||||
comment: 'No email found for ' + analysisOrder.user_id,
|
||||
relatedRecordId: analysisOrder.id,
|
||||
});
|
||||
logger.warn(
|
||||
{ analysisOrderId: analysisOrder.id, namespace: this.namespace },
|
||||
'No email found ',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [{ data: medusaOrder }, { data: analysisElements }] =
|
||||
await Promise.all([
|
||||
supabase
|
||||
.from('order')
|
||||
.select('id,metadata')
|
||||
.eq('id', analysisOrder.medusa_order_id)
|
||||
.single()
|
||||
.throwOnError(),
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_elements')
|
||||
.select('materialGroups:material_groups')
|
||||
.in('id', analysisOrder.analysis_element_ids ?? [])
|
||||
.throwOnError(),
|
||||
]);
|
||||
|
||||
let isUrine = false;
|
||||
for (const analysisElement of analysisElements ?? []) {
|
||||
logger.info({ group: analysisElement.materialGroups ?? [] });
|
||||
|
||||
const containsUrineSample = (analysisElement.materialGroups ?? [])?.some(
|
||||
(element) =>
|
||||
(element as { Materjal?: { MaterjaliNimi: string } })?.Materjal
|
||||
?.MaterjaliNimi === 'Uriin',
|
||||
);
|
||||
|
||||
if (containsUrineSample) {
|
||||
isUrine = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const orderMetadata = medusaOrder.metadata as {
|
||||
partner_location_name?: string;
|
||||
};
|
||||
|
||||
await sendEmailFromTemplate(
|
||||
renderOrderProcessingEmail,
|
||||
{
|
||||
language: userContact.preferred_locale ?? 'et',
|
||||
recipientName: getFullName(userContact.name, userContact.last_name),
|
||||
partnerLocation: orderMetadata.partner_location_name ?? 'SYNLAB',
|
||||
isUrine,
|
||||
},
|
||||
userContact.email,
|
||||
);
|
||||
|
||||
return createNotificationLog({
|
||||
action: NotificationAction.PATIENT_ORDER_PROCESSING,
|
||||
status: 'SUCCESS',
|
||||
relatedRecordId: analysisOrder.id,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPatientUpdateNotification(
|
||||
analysisOrder: AnalysisOrder,
|
||||
template: EmailRenderer,
|
||||
action: NotificationAction,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const userContact = await getUserContactAdmin(analysisOrder.user_id);
|
||||
|
||||
if (userContact?.email) {
|
||||
await sendEmailFromTemplate(
|
||||
template,
|
||||
{
|
||||
analysisOrderId: analysisOrder.id,
|
||||
recipientName: getFullName(userContact.name, userContact.last_name),
|
||||
language: userContact.preferred_locale ?? 'et',
|
||||
},
|
||||
userContact.email,
|
||||
);
|
||||
await createNotificationLog({
|
||||
action,
|
||||
status: 'SUCCESS',
|
||||
relatedRecordId: analysisOrder.id,
|
||||
});
|
||||
logger.info(
|
||||
{ analysisOrderId: analysisOrder.id, namespace: this.namespace },
|
||||
'Sent notification email',
|
||||
);
|
||||
} else {
|
||||
await createNotificationLog({
|
||||
action,
|
||||
status: 'FAIL',
|
||||
comment: 'No email found for ' + analysisOrder.user_id,
|
||||
relatedRecordId: analysisOrder.id,
|
||||
});
|
||||
logger.warn(
|
||||
{ analysisOrderId: analysisOrder.id, namespace: this.namespace },
|
||||
'No email found ',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async sendPartialAnalysisResultsNotifications(analysisOrder: AnalysisOrder) {
|
||||
const logger = await getLogger();
|
||||
|
||||
await this.sendPatientUpdateNotification(
|
||||
analysisOrder,
|
||||
renderPatientFirstResultsReceivedEmail,
|
||||
NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED,
|
||||
);
|
||||
|
||||
const doctorAccounts = await getDoctorAccounts();
|
||||
const doctorEmails: string[] = doctorAccounts
|
||||
.map(({ email }) => email)
|
||||
.filter((email): email is string => !!email);
|
||||
|
||||
await sendEmailFromTemplate(
|
||||
renderFirstResultsReceivedEmail,
|
||||
{
|
||||
analysisOrderId: analysisOrder.id,
|
||||
language: 'et',
|
||||
},
|
||||
doctorEmails,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{ analysisOrderId: analysisOrder.id, namespace: this.namespace },
|
||||
'Sent out partial analysis results notifications for doctors',
|
||||
);
|
||||
|
||||
await createNotificationLog({
|
||||
action: NotificationAction.DOCTOR_NEW_JOBS,
|
||||
status: 'SUCCESS',
|
||||
relatedRecordId: analysisOrder.id,
|
||||
});
|
||||
}
|
||||
|
||||
async sendFullAnalysisResultsNotifications(analysisOrder: AnalysisOrder) {
|
||||
await this.sendPatientUpdateNotification(
|
||||
analysisOrder,
|
||||
renderPatientFullResultsReceivedEmail,
|
||||
NotificationAction.PATIENT_FULL_RESULTS_RECEIVED,
|
||||
);
|
||||
|
||||
const doctorAccount = await getAssignedDoctorAccount(analysisOrder.id);
|
||||
const assignedDoctorEmail = doctorAccount?.email;
|
||||
|
||||
if (!assignedDoctorEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmailFromTemplate(
|
||||
renderAllResultsReceivedEmail,
|
||||
{
|
||||
analysisOrderId: analysisOrder.id,
|
||||
language: 'et',
|
||||
},
|
||||
assignedDoctorEmail,
|
||||
);
|
||||
|
||||
return createNotificationLog({
|
||||
action: NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED,
|
||||
status: 'SUCCESS',
|
||||
relatedRecordId: analysisOrder.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,10 @@ import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { StoreProduct } from '@medusajs/types';
|
||||
import { Button } from '@medusajs/ui';
|
||||
import type { AdminProductVariant, StoreProduct } from '@medusajs/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -18,18 +16,27 @@ import {
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from '@kit/ui/card';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { ButtonTooltip } from './ui/button-tooltip';
|
||||
import { PackageHeader } from './package-header';
|
||||
import { pathsConfig } from '../config';
|
||||
|
||||
export type AnalysisPackageWithVariant = Pick<StoreProduct, 'title' | 'description' | 'subtitle' | 'metadata'> & {
|
||||
import { handleAddToCart } from '~/lib/services/medusaCart.service';
|
||||
|
||||
import { pathsConfig } from '../config';
|
||||
import { PackageHeader } from './package-header';
|
||||
import { ButtonTooltip } from './ui/button-tooltip';
|
||||
|
||||
export type AnalysisPackageWithVariant = Pick<
|
||||
StoreProduct,
|
||||
'title' | 'description' | 'subtitle' | 'metadata'
|
||||
> & {
|
||||
variantId: string;
|
||||
nrOfAnalyses: number;
|
||||
price: number;
|
||||
isStandard: boolean;
|
||||
isStandardPlus: boolean;
|
||||
isPremium: boolean;
|
||||
variant: Pick<AdminProductVariant, 'metadata'>;
|
||||
};
|
||||
|
||||
export default function SelectAnalysisPackage({
|
||||
@@ -37,7 +44,7 @@ export default function SelectAnalysisPackage({
|
||||
countryCode,
|
||||
}: {
|
||||
analysisPackage: AnalysisPackageWithVariant;
|
||||
countryCode: string,
|
||||
countryCode: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
@@ -46,8 +53,15 @@ export default function SelectAnalysisPackage({
|
||||
} = useTranslation();
|
||||
|
||||
const [isAddingToCart, setIsAddingToCart] = useState(false);
|
||||
|
||||
const { nrOfAnalyses, variantId, title, subtitle = '', description = '', price } = analysisPackage;
|
||||
|
||||
const {
|
||||
nrOfAnalyses,
|
||||
variantId,
|
||||
title,
|
||||
subtitle = '',
|
||||
description = '',
|
||||
price,
|
||||
} = analysisPackage;
|
||||
|
||||
const handleSelect = async () => {
|
||||
setIsAddingToCart(true);
|
||||
@@ -57,10 +71,16 @@ export default function SelectAnalysisPackage({
|
||||
countryCode,
|
||||
});
|
||||
setIsAddingToCart(false);
|
||||
toast.success(<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />);
|
||||
toast.success(
|
||||
<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />,
|
||||
);
|
||||
router.push(pathsConfig.app.cart);
|
||||
} catch (e) {
|
||||
toast.error(<Trans i18nKey={'order-analysis-package:analysisPackageAddToCartError'} />);
|
||||
toast.error(
|
||||
<Trans
|
||||
i18nKey={'order-analysis-package:analysisPackageAddToCartError'}
|
||||
/>,
|
||||
);
|
||||
setIsAddingToCart(false);
|
||||
console.error(e);
|
||||
}
|
||||
@@ -86,7 +106,7 @@ export default function SelectAnalysisPackage({
|
||||
<CardContent className="space-y-1 text-center">
|
||||
<PackageHeader
|
||||
title={title}
|
||||
tagColor='bg-cyan'
|
||||
tagColor="bg-cyan"
|
||||
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
|
||||
language={language}
|
||||
price={price}
|
||||
@@ -94,8 +114,15 @@ export default function SelectAnalysisPackage({
|
||||
<CardDescription>{subtitle}</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full text-[10px] sm:text-sm" onClick={handleSelect} isLoading={isAddingToCart}>
|
||||
{!isAddingToCart && <Trans i18nKey='order-analysis-package:selectThisPackage' />}
|
||||
<Button
|
||||
className="w-full text-[10px] sm:text-sm"
|
||||
onClick={handleSelect}
|
||||
>
|
||||
{isAddingToCart ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Trans i18nKey="order-analysis-package:selectThisPackage" />
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function InfoTooltip({
|
||||
<TooltipTrigger>
|
||||
{icon || <Info className="size-4 cursor-pointer" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='sm:max-w-[400px]'>{content}</TooltipContent>
|
||||
<TooltipContent className='max-w-[90vw] sm:max-w-[400px]'>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
144
packages/shared/src/config/auth-providers.service.ts
Normal file
144
packages/shared/src/config/auth-providers.service.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
import authConfig from './auth.config';
|
||||
|
||||
type SupabaseExternalProvider = Provider | 'email';
|
||||
interface SupabaseAuthSettings {
|
||||
external: Record<SupabaseExternalProvider, boolean>;
|
||||
disable_signup: boolean;
|
||||
mailer_autoconfirm: boolean;
|
||||
}
|
||||
|
||||
export class AuthProvidersService {
|
||||
private supabaseUrl: string;
|
||||
private cache: Map<string, { data: SupabaseAuthSettings; timestamp: number }> = new Map();
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(supabaseUrl: string) {
|
||||
this.supabaseUrl = supabaseUrl;
|
||||
}
|
||||
|
||||
async fetchAuthSettings(): Promise<SupabaseAuthSettings | null> {
|
||||
try {
|
||||
const cacheKey = 'auth-settings';
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!anonKey) {
|
||||
throw new Error('NEXT_PUBLIC_SUPABASE_ANON_KEY is required');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.supabaseUrl}/auth/v1/settings?apikey=${anonKey}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to fetch auth settings from Supabase:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings: SupabaseAuthSettings = await response.json();
|
||||
|
||||
this.cache.set(cacheKey, { data: settings, timestamp: Date.now() });
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.warn('Error fetching auth settings from Supabase:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isPasswordEnabled({ settings }: { settings: SupabaseAuthSettings | null }): boolean {
|
||||
if (settings) {
|
||||
return settings.external.email === true && !settings.disable_signup;
|
||||
}
|
||||
|
||||
return process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true';
|
||||
}
|
||||
|
||||
isMailerAutoconfirmEnabled({ settings }: { settings: SupabaseAuthSettings | null }): boolean {
|
||||
return settings?.mailer_autoconfirm === true;
|
||||
}
|
||||
|
||||
isMagicLinkEnabled(): boolean {
|
||||
return process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true';
|
||||
}
|
||||
|
||||
isOAuthProviderEnabled({
|
||||
provider,
|
||||
settings,
|
||||
}: {
|
||||
provider: SupabaseExternalProvider;
|
||||
settings: SupabaseAuthSettings | null;
|
||||
}): boolean {
|
||||
if (settings && settings.external) {
|
||||
return settings.external[provider] === true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getEnabledOAuthProviders({ settings }: { settings: SupabaseAuthSettings | null }): SupabaseExternalProvider[] {
|
||||
const enabledProviders: SupabaseExternalProvider[] = [];
|
||||
|
||||
if (settings && settings.external) {
|
||||
for (const [providerName, isEnabled] of Object.entries(settings.external)) {
|
||||
if (isEnabled && providerName !== 'email') {
|
||||
enabledProviders.push(providerName as SupabaseExternalProvider);
|
||||
}
|
||||
}
|
||||
return enabledProviders;
|
||||
}
|
||||
|
||||
const potentialProviders: SupabaseExternalProvider[] = ['keycloak'];
|
||||
const enabledFallback: SupabaseExternalProvider[] = [];
|
||||
|
||||
for (const provider of potentialProviders) {
|
||||
if (provider !== 'email' && this.isOAuthProviderEnabled({ provider, settings })) {
|
||||
enabledFallback.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
return enabledFallback;
|
||||
}
|
||||
|
||||
async getAuthConfig() {
|
||||
const settings = await this.fetchAuthSettings();
|
||||
const [passwordEnabled, magicLinkEnabled, oAuthProviders, isMailerAutoconfirmEnabled] = await Promise.all([
|
||||
this.isPasswordEnabled({ settings }),
|
||||
this.isMagicLinkEnabled(),
|
||||
this.getEnabledOAuthProviders({ settings }),
|
||||
this.isMailerAutoconfirmEnabled({ settings }),
|
||||
]);
|
||||
|
||||
return {
|
||||
providers: {
|
||||
password: passwordEnabled,
|
||||
magicLink: magicLinkEnabled,
|
||||
oAuth: oAuthProviders,
|
||||
},
|
||||
displayTermsCheckbox: authConfig.displayTermsCheckbox,
|
||||
isMailerAutoconfirmEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthProvidersService(): AuthProvidersService {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error('NEXT_PUBLIC_SUPABASE_URL is required');
|
||||
}
|
||||
|
||||
return new AuthProvidersService(supabaseUrl);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ const authConfig = AuthConfigSchema.parse({
|
||||
providers: {
|
||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||
oAuth: ['google'],
|
||||
oAuth: ['keycloak'],
|
||||
},
|
||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||
|
||||
|
||||
114
packages/shared/src/config/dynamic-auth.config.ts
Normal file
114
packages/shared/src/config/dynamic-auth.config.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
import { z } from 'zod';
|
||||
import { createAuthProvidersService } from './auth-providers.service';
|
||||
|
||||
const providers: z.ZodType<Provider> = getProviders();
|
||||
|
||||
const DynamicAuthConfigSchema = z.object({
|
||||
providers: z.object({
|
||||
password: z.boolean().describe('Enable password authentication.'),
|
||||
magicLink: z.boolean().describe('Enable magic link authentication.'),
|
||||
oAuth: providers.array(),
|
||||
}),
|
||||
displayTermsCheckbox: z.boolean().describe('Whether to display the terms checkbox during sign-up.'),
|
||||
isMailerAutoconfirmEnabled: z.boolean().describe('Whether Supabase sends confirmation email automatically.'),
|
||||
});
|
||||
|
||||
export type DynamicAuthConfig = {
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
displayTermsCheckbox: boolean | undefined;
|
||||
isMailerAutoconfirmEnabled: boolean;
|
||||
}
|
||||
|
||||
export async function getDynamicAuthConfig() {
|
||||
const authService = createAuthProvidersService();
|
||||
const dynamicProviders = await authService.getAuthConfig();
|
||||
|
||||
const config = {
|
||||
providers: dynamicProviders.providers,
|
||||
displayTermsCheckbox: dynamicProviders.displayTermsCheckbox,
|
||||
isMailerAutoconfirmEnabled: dynamicProviders.isMailerAutoconfirmEnabled,
|
||||
};
|
||||
|
||||
return DynamicAuthConfigSchema.parse(config);
|
||||
}
|
||||
|
||||
export async function getCachedAuthConfig() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const cached = sessionStorage.getItem('auth-config');
|
||||
if (cached) {
|
||||
try {
|
||||
const { data, timestamp } = JSON.parse(cached);
|
||||
// Cache for 5 minutes
|
||||
if (Date.now() - timestamp < 5 * 60 * 1000) {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Invalid auth config cache:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = await getDynamicAuthConfig();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
sessionStorage.setItem('auth-config', JSON.stringify({
|
||||
data: config,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache auth config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function getServerAuthConfig() {
|
||||
return getDynamicAuthConfig();
|
||||
}
|
||||
|
||||
export async function isProviderEnabled(provider: 'password' | 'magicLink' | Provider): Promise<boolean> {
|
||||
const authService = createAuthProvidersService();
|
||||
const settings = await authService.fetchAuthSettings();
|
||||
|
||||
switch (provider) {
|
||||
case 'password':
|
||||
return authService.isPasswordEnabled({ settings });
|
||||
case 'magicLink':
|
||||
return authService.isMagicLinkEnabled();
|
||||
default:
|
||||
return authService.isOAuthProviderEnabled({ provider, settings });
|
||||
}
|
||||
}
|
||||
|
||||
function getProviders() {
|
||||
return z.enum([
|
||||
'apple',
|
||||
'azure',
|
||||
'bitbucket',
|
||||
'discord',
|
||||
'facebook',
|
||||
'figma',
|
||||
'github',
|
||||
'gitlab',
|
||||
'google',
|
||||
'kakao',
|
||||
'keycloak',
|
||||
'linkedin',
|
||||
'linkedin_oidc',
|
||||
'notion',
|
||||
'slack',
|
||||
'spotify',
|
||||
'twitch',
|
||||
'twitter',
|
||||
'workos',
|
||||
'zoom',
|
||||
'fly',
|
||||
]);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createPath,
|
||||
getTeamAccountSidebarConfig,
|
||||
} from './team-account-navigation.config';
|
||||
import { DynamicAuthConfig, getCachedAuthConfig, getServerAuthConfig } from './dynamic-auth.config';
|
||||
|
||||
export {
|
||||
appConfig,
|
||||
@@ -18,4 +19,7 @@ export {
|
||||
getTeamAccountSidebarConfig,
|
||||
pathsConfig,
|
||||
personalAccountNavigationConfig,
|
||||
getCachedAuthConfig,
|
||||
getServerAuthConfig,
|
||||
type DynamicAuthConfig,
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './use-csrf-token';
|
||||
export * from './use-current-locale-language-names';
|
||||
export * from './use-auth-config';
|
||||
|
||||
76
packages/shared/src/hooks/use-auth-config.ts
Normal file
76
packages/shared/src/hooks/use-auth-config.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
import { getCachedAuthConfig } from '../config/dynamic-auth.config';
|
||||
import { authConfig } from '../config';
|
||||
|
||||
interface AuthConfig {
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAuthConfigResult {
|
||||
config: AuthConfig | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAuthConfig(): UseAuthConfigResult {
|
||||
const [config, setConfig] = useState<AuthConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const authConfig = await getCachedAuthConfig();
|
||||
setConfig(authConfig);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch auth config', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch auth config'));
|
||||
setConfig(authConfig);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProviderEnabled(provider: 'password' | 'magicLink' | Provider) {
|
||||
const { config, loading, error } = useAuthConfig();
|
||||
|
||||
const isEnabled = (() => {
|
||||
if (!config) return false;
|
||||
|
||||
switch (provider) {
|
||||
case 'password':
|
||||
return config.providers.password;
|
||||
case 'magicLink':
|
||||
return config.providers.magicLink;
|
||||
default:
|
||||
return config.providers.oAuth.includes(provider);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
enabled: isEnabled,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { format } from 'date-fns';
|
||||
import Isikukood, { Gender } from 'isikukood';
|
||||
import Isikukood from 'isikukood';
|
||||
|
||||
/**
|
||||
* Check if the code is running in a browser environment.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
"paths": {
|
||||
"~/lib/*": ["../../lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AuthError,
|
||||
type EmailOtpType,
|
||||
SupabaseClient,
|
||||
User,
|
||||
} from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) {
|
||||
* @description Service for handling auth callbacks in Supabase
|
||||
*/
|
||||
class AuthCallbackService {
|
||||
constructor(private readonly client: SupabaseClient) {}
|
||||
constructor(private readonly client: SupabaseClient) { }
|
||||
|
||||
/**
|
||||
* @name verifyTokenHash
|
||||
@@ -128,89 +129,117 @@ class AuthCallbackService {
|
||||
/**
|
||||
* @name exchangeCodeForSession
|
||||
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
|
||||
* @param request
|
||||
* @param params
|
||||
* @param authCode
|
||||
*/
|
||||
async exchangeCodeForSession(
|
||||
request: Request,
|
||||
params: {
|
||||
joinTeamPath: string;
|
||||
redirectPath: string;
|
||||
errorPath?: string;
|
||||
},
|
||||
): Promise<{
|
||||
nextPath: string;
|
||||
}> {
|
||||
const requestUrl = new URL(request.url);
|
||||
const searchParams = requestUrl.searchParams;
|
||||
async exchangeCodeForSession(authCode: string): Promise<{
|
||||
isSuccess: boolean;
|
||||
user: User;
|
||||
} | ErrorURLParameters> {
|
||||
let user: User;
|
||||
try {
|
||||
const { data, error } =
|
||||
await this.client.auth.exchangeCodeForSession(authCode);
|
||||
|
||||
const authCode = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const nextUrlPathFromParams = searchParams.get('next');
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||
|
||||
let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
|
||||
|
||||
// if we have an invite token, we redirect to the join team page
|
||||
// instead of the default next url. This is because the user is trying
|
||||
// to join a team and we want to make sure they are redirected to the
|
||||
// correct page.
|
||||
if (inviteToken) {
|
||||
const emailParam = searchParams.get('email');
|
||||
|
||||
const urlParams = new URLSearchParams({
|
||||
invite_token: inviteToken,
|
||||
email: emailParam ?? '',
|
||||
});
|
||||
|
||||
nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`;
|
||||
}
|
||||
|
||||
if (authCode) {
|
||||
try {
|
||||
const { error } =
|
||||
await this.client.auth.exchangeCodeForSession(authCode);
|
||||
|
||||
// if we have an error, we redirect to the error page
|
||||
if (error) {
|
||||
return onError({
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
path: errorPath,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
{
|
||||
error,
|
||||
name: `auth.callback`,
|
||||
},
|
||||
`An error occurred while exchanging code for session`,
|
||||
);
|
||||
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
|
||||
return onError({
|
||||
code: (error as AuthError)?.code,
|
||||
error: message as string,
|
||||
path: errorPath,
|
||||
// if we have an error, we redirect to the error page
|
||||
if (error) {
|
||||
return getErrorURLParameters({
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return onError({
|
||||
error,
|
||||
path: errorPath,
|
||||
// Handle Keycloak users - set up Medusa integration
|
||||
if (data?.user && this.isKeycloakUser(data.user)) {
|
||||
await this.setupMedusaUserForKeycloak(data.user);
|
||||
}
|
||||
|
||||
user = data.user;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
{
|
||||
error,
|
||||
name: `auth.callback`,
|
||||
},
|
||||
`An error occurred while exchanging code for session`,
|
||||
);
|
||||
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
|
||||
return getErrorURLParameters({
|
||||
code: (error as AuthError)?.code,
|
||||
error: message as string,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nextPath: nextUrl,
|
||||
isSuccess: true,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is from Keycloak provider
|
||||
*/
|
||||
private isKeycloakUser(user: any): boolean {
|
||||
return user?.app_metadata?.provider === 'keycloak' ||
|
||||
user?.app_metadata?.providers?.includes('keycloak');
|
||||
}
|
||||
|
||||
private async setupMedusaUserForKeycloak(user: any): Promise<void> {
|
||||
if (!user.email) {
|
||||
console.warn('Keycloak user has no email, skipping Medusa setup');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user already has medusa_account_id
|
||||
const { data: accountData, error: fetchError } = await this.client
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.select('medusa_account_id, name, last_name')
|
||||
.eq('primary_owner_user_id', user.id)
|
||||
.eq('is_personal_account', true)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
console.error('Error fetching account data for Keycloak user:', fetchError);
|
||||
return;
|
||||
}
|
||||
|
||||
// If user already has Medusa account, we're done
|
||||
if (accountData?.medusa_account_id) {
|
||||
console.log('Keycloak user already has Medusa account:', accountData.medusa_account_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const { medusaLoginOrRegister } = await import('../../features/medusa-storefront/src/lib/data/customer');
|
||||
|
||||
const medusaAccountId = await medusaLoginOrRegister({
|
||||
email: user.email,
|
||||
supabaseUserId: user.id,
|
||||
name: accountData?.name ?? '-',
|
||||
lastName: accountData?.last_name ?? '-',
|
||||
});
|
||||
|
||||
// Update the account with the Medusa account ID
|
||||
const { error: updateError } = await this.client
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.update({ medusa_account_id: medusaAccountId })
|
||||
.eq('primary_owner_user_id', user.id)
|
||||
.eq('is_personal_account', true);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating account with Medusa ID:', updateError);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Successfully set up Medusa account for Keycloak user:', medusaAccountId);
|
||||
} catch (error) {
|
||||
console.error('Error setting up Medusa account for Keycloak user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
|
||||
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
||||
url.host = host as string;
|
||||
@@ -231,15 +260,19 @@ class AuthCallbackService {
|
||||
}
|
||||
}
|
||||
|
||||
function onError({
|
||||
interface ErrorURLParameters {
|
||||
error: string;
|
||||
code?: string;
|
||||
searchParams: string;
|
||||
}
|
||||
|
||||
export function getErrorURLParameters({
|
||||
error,
|
||||
path,
|
||||
code,
|
||||
}: {
|
||||
error: string;
|
||||
path: string;
|
||||
code?: string;
|
||||
}) {
|
||||
}): ErrorURLParameters {
|
||||
const errorMessage = getAuthErrorMessage({ error, code });
|
||||
|
||||
console.error(
|
||||
@@ -255,10 +288,10 @@ function onError({
|
||||
code: code ?? '',
|
||||
});
|
||||
|
||||
const nextPath = `${path}?${searchParams.toString()}`;
|
||||
|
||||
return {
|
||||
nextPath,
|
||||
error: errorMessage,
|
||||
code: code ?? '',
|
||||
searchParams: searchParams.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
||||
export function getSupabaseBrowserClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey);
|
||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ export function createMiddlewareClient<GenericSchema = Database>(
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
|
||||
@@ -15,6 +15,11 @@ export function getSupabaseServerClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
cookies: {
|
||||
async getAll() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
@@ -1257,6 +1257,26 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
medipost_actions: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: number
|
||||
action: string
|
||||
xml: string
|
||||
has_analysis_results: boolean
|
||||
medusa_order_id: string
|
||||
response_xml: string
|
||||
has_error: boolean
|
||||
}
|
||||
Insert: {
|
||||
action: string
|
||||
xml: string
|
||||
has_analysis_results: boolean
|
||||
medusa_order_id: string
|
||||
response_xml: string
|
||||
has_error: boolean
|
||||
}
|
||||
}
|
||||
medreport_product_groups: {
|
||||
Row: {
|
||||
created_at: string
|
||||
@@ -2053,6 +2073,7 @@ export type Database = {
|
||||
p_personal_code: string
|
||||
p_phone: string
|
||||
p_uid: string
|
||||
p_email: string
|
||||
}
|
||||
Returns: undefined
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function useSignInWithEmailPassword() {
|
||||
const medusaAccountId = await medusaLoginOrRegister({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
isDevPasswordLogin: true,
|
||||
});
|
||||
await client
|
||||
.schema('medreport').from('accounts')
|
||||
|
||||
@@ -9,7 +9,13 @@ export function useSignInWithProvider() {
|
||||
const mutationKey = ['auth', 'sign-in-with-provider'];
|
||||
|
||||
const mutationFn = async (credentials: SignInWithOAuthCredentials) => {
|
||||
const response = await client.auth.signInWithOAuth(credentials);
|
||||
const response = await client.auth.signInWithOAuth({
|
||||
...credentials,
|
||||
options: {
|
||||
...credentials.options,
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error.message;
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
import { signout } from '../../../features/medusa-storefront/src/lib/data/customer';
|
||||
|
||||
export function useSignOut() {
|
||||
const client = useSupabase();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await signout(undefined, false);
|
||||
return client.auth.signOut();
|
||||
try {
|
||||
try {
|
||||
const { medusaLogout } = await import('../../../features/medusa-storefront/src/lib/data/customer');
|
||||
await medusaLogout(undefined, false);
|
||||
} catch (medusaError) {
|
||||
console.warn('Medusa logout failed or not available:', medusaError);
|
||||
}
|
||||
|
||||
const { error } = await client.auth.signOut();
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function useSignUpWithEmailAndPassword() {
|
||||
const medusaAccountId = await medusaLoginOrRegister({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
isDevPasswordLogin: true,
|
||||
});
|
||||
await client
|
||||
.schema('medreport').from('accounts')
|
||||
|
||||
@@ -28,8 +28,8 @@ export function useUser(initialData?: User | null) {
|
||||
queryFn,
|
||||
queryKey,
|
||||
initialData,
|
||||
refetchInterval: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 2_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
@@ -52,9 +53,13 @@ export function AppBreadcrumbs(props: {
|
||||
/>
|
||||
);
|
||||
|
||||
const isLast = index === visiblePaths.length - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<BreadcrumbItem className={'capitalize lg:text-xs'}>
|
||||
<BreadcrumbItem className={clsx('lg:text-xs', {
|
||||
'font-bold text-black': isLast,
|
||||
})}>
|
||||
<If
|
||||
condition={index < visiblePaths.length - 1}
|
||||
fallback={label}
|
||||
@@ -64,6 +69,7 @@ export function AppBreadcrumbs(props: {
|
||||
'/' +
|
||||
splitPath.slice(0, splitPath.indexOf(path) + 1).join('/')
|
||||
}
|
||||
className='text-muted-foreground'
|
||||
>
|
||||
{label}
|
||||
</BreadcrumbLink>
|
||||
@@ -77,7 +83,7 @@ export function AppBreadcrumbs(props: {
|
||||
</>
|
||||
)}
|
||||
|
||||
<If condition={index !== visiblePaths.length - 1}>
|
||||
<If condition={!isLast}>
|
||||
<BreadcrumbSeparator />
|
||||
</If>
|
||||
</Fragment>
|
||||
|
||||
@@ -53,6 +53,7 @@ export function LanguageSelector({
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
localStorage.setItem('lang', locale);
|
||||
return i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ function PageWithSidebar(props: PageProps) {
|
||||
>
|
||||
{MobileNavigation}
|
||||
|
||||
<div className={'bg-background flex flex-1 flex-col px-4 pb-8 lg:px-0'}>
|
||||
<div className={'bg-background flex flex-1 flex-col px-2 pb-8 lg:px-0'}>
|
||||
{Children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,7 +106,7 @@ export function PageBody(
|
||||
}>,
|
||||
) {
|
||||
const className = cn(
|
||||
'flex w-full flex-1 flex-col space-y-6 lg:px-4',
|
||||
'flex w-full flex-1 flex-col space-y-6',
|
||||
props.className,
|
||||
);
|
||||
|
||||
@@ -119,8 +119,8 @@ export function PageNavigation(props: React.PropsWithChildren) {
|
||||
|
||||
export function PageDescription(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className={'flex h-6 items-center'}>
|
||||
<div className={'text-muted-foreground text-sm leading-none font-normal'}>
|
||||
<div className={'flex items-center'}>
|
||||
<div className={'text-muted-foreground text-sm font-normal leading-6'}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,7 +158,7 @@ export function PageHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between py-5 lg:px-4',
|
||||
'flex items-center justify-between py-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -168,7 +168,7 @@ export function PageHeader({
|
||||
</If>
|
||||
|
||||
<If condition={displaySidebarTrigger || description}>
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{displaySidebarTrigger ? (
|
||||
<SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground hidden h-4.5 w-4.5 cursor-pointer lg:inline-flex" />
|
||||
) : null}
|
||||
|
||||
@@ -34,7 +34,7 @@ const CardHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
<div className={cn('flex flex-col space-y-1.5 p-4 sm:p-6', className)} {...props} />
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
@@ -60,14 +60,14 @@ CardDescription.displayName = 'CardDescription';
|
||||
const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => <div className={cn('p-6 pt-0', className)} {...props} />;
|
||||
}) => <div className={cn('p-4 sm:p-6 pt-0', className)} {...props} />;
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
<div className={cn('flex items-center p-4 sm:p-6 pt-0', className)} {...props} />
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user