Merge branch 'develop' of https://github.com/MR-medreport/MRB2B into MED-103
This commit is contained in:
8
.env
8
.env
@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
|
||||
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
||||
|
||||
# AUTH
|
||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||
NEXT_PUBLIC_AUTH_PASSWORD=false
|
||||
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||
|
||||
@@ -65,3 +65,9 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
|
||||
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
||||
|
||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
||||
|
||||
# Configure Medusa password secret for Keycloak users
|
||||
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==
|
||||
|
||||
# False by default
|
||||
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
# SITE
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||
|
||||
# SUPABASE DEVELOPMENT
|
||||
|
||||
@@ -25,6 +26,22 @@ EMAIL_PORT=1025 # or 465 for SSL
|
||||
EMAIL_TLS=false
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
# MEDIPOST
|
||||
|
||||
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
|
||||
MEDIPOST_USER=trvurgtst
|
||||
MEDIPOST_PASSWORD=SRB48HZMV
|
||||
MEDIPOST_RECIPIENT=trvurgtst
|
||||
MEDIPOST_MESSAGE_SENDER=trvurgtst
|
||||
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true
|
||||
|
||||
#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
|
||||
#MEDIPOST_USER=medreport
|
||||
#MEDIPOST_PASSWORD=
|
||||
#MEDIPOST_RECIPIENT=HTI
|
||||
#MEDIPOST_MESSAGE_SENDER=medreport
|
||||
#MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false
|
||||
|
||||
# MEDUSA
|
||||
MEDUSA_BACKEND_URL=http://localhost:9000
|
||||
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
|
||||
|
||||
@@ -13,10 +13,8 @@ import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { featureFlagsConfig } from '@kit/shared/config';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
||||
import { useAuthConfig } from '@kit/shared/hooks';
|
||||
|
||||
const ModeToggle = dynamic(() =>
|
||||
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||
@@ -60,6 +58,8 @@ export function SiteHeaderAccountSection({
|
||||
}
|
||||
|
||||
function AuthButtons() {
|
||||
const { config } = useAuthConfig();
|
||||
|
||||
return (
|
||||
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
|
||||
<div className={'hidden md:flex'}>
|
||||
@@ -68,19 +68,25 @@ function AuthButtons() {
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<div className={'flex gap-x-2.5'}>
|
||||
<Button className={'block'} asChild variant={'ghost'}>
|
||||
<Link href={pathsConfig.auth.signIn}>
|
||||
<Trans i18nKey={'auth:signIn'} />
|
||||
</Link>
|
||||
</Button>
|
||||
{config && (
|
||||
<div className={'flex gap-x-2.5'}>
|
||||
{(config.providers.password || config.providers.oAuth.length > 0) && (
|
||||
<Button className={'block'} asChild variant={'ghost'}>
|
||||
<Link href={pathsConfig.auth.signIn}>
|
||||
<Trans i18nKey={'auth:signIn'} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Trans i18nKey={'auth:signUp'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{config.providers.password && (
|
||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Trans i18nKey={'auth:signUp'} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
|
||||
import { CtaButton, Hero } from '@kit/ui/marketing';
|
||||
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
|
||||
return (
|
||||
<div className={'flex space-x-4'}>
|
||||
<CtaButton>
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<span className={'flex items-center space-x-0.5'}>
|
||||
<span>
|
||||
<Trans i18nKey={'common:getStarted'} />
|
||||
|
||||
@@ -6,11 +6,12 @@ type ProcessedMessage = {
|
||||
hasPartialAnalysisResponse: boolean;
|
||||
hasFullAnalysisResponse: boolean;
|
||||
medusaOrderId: string | undefined;
|
||||
analysisOrderId: number | undefined;
|
||||
};
|
||||
|
||||
type GroupedResults = {
|
||||
processed: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
|
||||
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
|
||||
processed: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
|
||||
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
|
||||
};
|
||||
|
||||
export default async function syncAnalysisResults() {
|
||||
@@ -37,14 +38,14 @@ export default async function syncAnalysisResults() {
|
||||
}
|
||||
|
||||
const groupedResults = processedMessages.reduce((acc, result) => {
|
||||
if (result.medusaOrderId) {
|
||||
if (result.analysisOrderId) {
|
||||
if (result.hasAnalysisResponse) {
|
||||
if (!acc.processed) {
|
||||
acc.processed = [];
|
||||
}
|
||||
acc.processed.push({
|
||||
messageId: result.messageId,
|
||||
medusaOrderId: result.medusaOrderId,
|
||||
analysisOrderId: result.analysisOrderId,
|
||||
});
|
||||
} else {
|
||||
if (!acc.waitingForResults) {
|
||||
@@ -52,7 +53,7 @@ export default async function syncAnalysisResults() {
|
||||
}
|
||||
acc.waitingForResults.push({
|
||||
messageId: result.messageId,
|
||||
medusaOrderId: result.medusaOrderId,
|
||||
analysisOrderId: result.analysisOrderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const POST = async (request: NextRequest) => {
|
||||
'Successfully sent out open job notification emails to doctors.',
|
||||
);
|
||||
await createNotificationLog({
|
||||
action: NotificationAction.NEW_JOBS_ALERT,
|
||||
action: NotificationAction.DOCTOR_NEW_JOBS,
|
||||
status: 'SUCCESS',
|
||||
});
|
||||
return NextResponse.json(
|
||||
@@ -39,7 +39,7 @@ export const POST = async (request: NextRequest) => {
|
||||
e,
|
||||
);
|
||||
await createNotificationLog({
|
||||
action: NotificationAction.NEW_JOBS_ALERT,
|
||||
action: NotificationAction.DOCTOR_NEW_JOBS,
|
||||
status: 'FAIL',
|
||||
comment: e?.message,
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||
orderId: medusaOrderId,
|
||||
orderId: medreportOrder.id,
|
||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getOrder } from "~/lib/services/order.service";
|
||||
import { getAnalysisOrder } from "~/lib/services/order.service";
|
||||
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
||||
import { retrieveOrder } from "@lib/data";
|
||||
import { getAccountAdmin } from "~/lib/services/account.service";
|
||||
@@ -14,9 +14,9 @@ export async function POST(request: Request) {
|
||||
const { medusaOrderId } = await request.json();
|
||||
|
||||
const medusaOrder = await retrieveOrder(medusaOrderId)
|
||||
const medreportOrder = await getOrder({ medusaOrderId });
|
||||
const analysisOrder = await getAnalysisOrder({ medusaOrderId });
|
||||
|
||||
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
|
||||
const account = await getAccountAdmin({ primaryOwnerUserId: analysisOrder.user_id });
|
||||
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
|
||||
|
||||
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
|
||||
@@ -29,8 +29,8 @@ export async function POST(request: Request) {
|
||||
},
|
||||
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||
orderId: medusaOrderId,
|
||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||
orderId: analysisOrder.id,
|
||||
orderCreatedAt: new Date(analysisOrder.created_at),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,19 +1,62 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||
import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
|
||||
|
||||
const ERROR_PATH = '/auth/callback/error';
|
||||
|
||||
const redirectOnError = (searchParams?: string) => {
|
||||
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const error = searchParams.get('error');
|
||||
if (error) {
|
||||
const { searchParams } = getErrorURLParameters({ error });
|
||||
return redirectOnError(searchParams);
|
||||
}
|
||||
|
||||
const authCode = searchParams.get('code');
|
||||
if (!authCode) {
|
||||
return redirectOnError();
|
||||
}
|
||||
|
||||
let redirectPath = searchParams.get('next') || pathsConfig.app.home;
|
||||
// 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.
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
if (inviteToken) {
|
||||
const urlParams = new URLSearchParams({
|
||||
invite_token: inviteToken,
|
||||
email: searchParams.get('email') ?? '',
|
||||
});
|
||||
|
||||
redirectPath = `${pathsConfig.app.joinTeam}?${urlParams.toString()}`;
|
||||
}
|
||||
|
||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||
const oauthResult = await service.exchangeCodeForSession(authCode);
|
||||
if (!("isSuccess" in oauthResult)) {
|
||||
return redirectOnError(oauthResult.searchParams);
|
||||
}
|
||||
|
||||
const { nextPath } = await service.exchangeCodeForSession(request, {
|
||||
joinTeamPath: pathsConfig.app.joinTeam,
|
||||
redirectPath: pathsConfig.app.home,
|
||||
});
|
||||
const api = createAccountsApi(getSupabaseServerClient());
|
||||
|
||||
return redirect(nextPath);
|
||||
const account = await api.getPersonalAccountByUserId(
|
||||
oauthResult.user.id,
|
||||
);
|
||||
|
||||
if (!account.email || !account.name || !account.last_name) {
|
||||
return redirect(pathsConfig.auth.updateAccount);
|
||||
}
|
||||
|
||||
return redirect(redirectPath);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||
|
||||
|
||||
56
app/auth/sign-in/components/PasswordOption.tsx
Normal file
56
app/auth/sign-in/components/PasswordOption.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Providers, SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export default function PasswordOption({
|
||||
inviteToken,
|
||||
returnPath,
|
||||
providers,
|
||||
}: {
|
||||
inviteToken?: string;
|
||||
returnPath?: string;
|
||||
providers: Providers;
|
||||
}) {
|
||||
const signUpPath =
|
||||
pathsConfig.auth.signUp +
|
||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||
|
||||
const paths = {
|
||||
callback: pathsConfig.auth.callback,
|
||||
returnPath: returnPath ?? pathsConfig.app.home,
|
||||
joinTeam: pathsConfig.app.joinTeam,
|
||||
updateAccount: pathsConfig.auth.updateAccount,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
<Heading level={4} className={'tracking-tight'}>
|
||||
<Trans i18nKey={'auth:signInHeading'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth:signInSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignInMethodsContainer
|
||||
inviteToken={inviteToken}
|
||||
paths={paths}
|
||||
providers={providers}
|
||||
/>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<Button asChild variant={'link'} size={'sm'}>
|
||||
<Link href={signUpPath} prefetch={true}>
|
||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import Loading from '@/app/home/loading';
|
||||
import { useEffect } from 'react';
|
||||
import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function SignInPageClientRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function signIn() {
|
||||
const { data, error } = await getSupabaseBrowserClient()
|
||||
.auth
|
||||
.signInWithOAuth({
|
||||
provider: 'keycloak',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
queryParams: {
|
||||
prompt: 'login',
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('OAuth error', error);
|
||||
router.push('/');
|
||||
} else if (data.url) {
|
||||
router.push(data.url);
|
||||
}
|
||||
}
|
||||
|
||||
signIn();
|
||||
}, [router]);
|
||||
|
||||
return <Loading />;
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
|
||||
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
|
||||
import PasswordOption from './components/PasswordOption';
|
||||
|
||||
interface SignInPageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -26,47 +21,26 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const { invite_token: inviteToken, next = pathsConfig.app.home } =
|
||||
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
|
||||
await searchParams;
|
||||
|
||||
const signUpPath =
|
||||
pathsConfig.auth.signUp +
|
||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||
const authConfig = await getServerAuthConfig();
|
||||
|
||||
const paths = {
|
||||
callback: pathsConfig.auth.callback,
|
||||
returnPath: next ?? pathsConfig.app.home,
|
||||
joinTeam: pathsConfig.app.joinTeam,
|
||||
updateAccount: pathsConfig.auth.updateAccount,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
<Heading level={4} className={'tracking-tight'}>
|
||||
<Trans i18nKey={'auth:signInHeading'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth:signInSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignInMethodsContainer
|
||||
if (authConfig.providers.password) {
|
||||
return (
|
||||
<PasswordOption
|
||||
inviteToken={inviteToken}
|
||||
paths={paths}
|
||||
returnPath={returnPath}
|
||||
providers={authConfig.providers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<Button asChild variant={'link'} size={'sm'}>
|
||||
<Link href={signUpPath} prefetch={true}>
|
||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (authConfig.providers.oAuth.includes('keycloak')) {
|
||||
return <SignInPageClientRedirect />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default withI18n(SignInPage);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
||||
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||
import { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
@@ -37,6 +38,12 @@ async function SignUpPage({ searchParams }: Props) {
|
||||
pathsConfig.auth.signIn +
|
||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||
|
||||
const authConfig = await getServerAuthConfig();
|
||||
|
||||
if (!authConfig.providers.password) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
@@ -50,8 +57,7 @@ async function SignUpPage({ searchParams }: Props) {
|
||||
</div>
|
||||
|
||||
<SignUpMethodsContainer
|
||||
providers={authConfig.providers}
|
||||
displayTermsCheckbox={authConfig.displayTermsCheckbox}
|
||||
authConfig={authConfig}
|
||||
inviteToken={inviteToken}
|
||||
paths={paths}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ExternalLink } from '@/public/assets/external-link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -21,40 +22,87 @@ import {
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
|
||||
import { UpdateAccountSchemaClient } from '../_lib/schemas/update-account.schema';
|
||||
import { onUpdateAccount } from '../_lib/server/update-account';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { pathsConfig } from '@/packages/shared/src/config';
|
||||
|
||||
type UpdateAccountFormValues = z.infer<ReturnType<typeof UpdateAccountSchemaClient>>;
|
||||
|
||||
export function UpdateAccountForm({
|
||||
defaultValues,
|
||||
isEmailUser,
|
||||
}: {
|
||||
defaultValues: UpdateAccountFormValues,
|
||||
isEmailUser: boolean,
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('account');
|
||||
|
||||
export function UpdateAccountForm({ user }: { user: User }) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(UpdateAccountSchema),
|
||||
resolver: zodResolver(UpdateAccountSchemaClient({ isEmailUser })),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
personalCode: '',
|
||||
email: user.email,
|
||||
phone: '',
|
||||
city: '',
|
||||
weight: 0,
|
||||
height: 0,
|
||||
userConsent: false,
|
||||
},
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { firstName, lastName, personalCode, email, userConsent } = defaultValues;
|
||||
|
||||
const defaultValues_weight = "weight" in defaultValues ? defaultValues.weight : null;
|
||||
const defaultValues_height = "height" in defaultValues ? defaultValues.height : null;
|
||||
|
||||
const hasFirstName = !!firstName;
|
||||
const hasLastName = !!lastName;
|
||||
const hasPersonalCode = !!personalCode;
|
||||
const hasEmail = !!email;
|
||||
|
||||
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) => {
|
||||
const loading = toast.loading(t('updateAccount.updateAccountLoading'));
|
||||
try {
|
||||
const response = await onUpdateAccount({
|
||||
firstName: values.firstName || firstName,
|
||||
lastName: values.lastName || lastName,
|
||||
personalCode: values.personalCode || personalCode,
|
||||
email: values.email || email,
|
||||
phone: values.phone,
|
||||
weight: ((("weight" in values && values.weight) ?? defaultValues_weight) || null) as number,
|
||||
height: ((("height" in values && values.height) ?? defaultValues_height) || null) as number,
|
||||
userConsent: values.userConsent ?? userConsent,
|
||||
city: values.city,
|
||||
});
|
||||
if (!response) {
|
||||
throw new Error('Failed to update account');
|
||||
}
|
||||
toast.dismiss(loading);
|
||||
toast.success(t('updateAccount.updateAccountSuccess'));
|
||||
|
||||
if (response.hasUnseenMembershipConfirmation) {
|
||||
router.push(pathsConfig.auth.membershipConfirmation);
|
||||
} else {
|
||||
router.push(pathsConfig.app.selectPackage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("promiseresult error", error);
|
||||
toast.error(t('updateAccount.updateAccountError'));
|
||||
toast.dismiss(loading);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
||||
onSubmit={form.handleSubmit(onUpdateAccount)}
|
||||
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
|
||||
>
|
||||
<FormField
|
||||
name="firstName"
|
||||
disabled={hasFirstName && !isEmailUser}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:firstName'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} autoFocus={!hasFirstName} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -63,13 +111,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
||||
|
||||
<FormField
|
||||
name="lastName"
|
||||
disabled={hasLastName && !isEmailUser}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:lastName'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input {...field} autoFocus={hasFirstName && !hasLastName} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -78,6 +127,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
||||
|
||||
<FormField
|
||||
name="personalCode"
|
||||
disabled={hasPersonalCode && !isEmailUser}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
@@ -93,13 +143,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
||||
|
||||
<FormField
|
||||
name="email"
|
||||
disabled={hasEmail}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:email'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled />
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -121,72 +172,76 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:city'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!isEmailUser && (
|
||||
<>
|
||||
<FormField
|
||||
name="city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:city'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-between gap-4">
|
||||
<FormField
|
||||
name="weight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:weight'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="kg"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === '' ? null : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-row justify-between gap-4">
|
||||
<FormField
|
||||
name="weight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:weight'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="kg"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === '' ? null : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="height"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:height'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="cm"
|
||||
type="number"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === '' ? null : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
name="height"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:height'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="cm"
|
||||
type="number"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === '' ? null : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
name="userConsent"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import Isikukood from 'isikukood';
|
||||
import parsePhoneNumber from 'libphonenumber-js/min';
|
||||
|
||||
export const UpdateAccountSchema = z.object({
|
||||
const updateAccountSchema = {
|
||||
firstName: z
|
||||
.string({
|
||||
error: 'First name is required',
|
||||
@@ -10,20 +12,42 @@ export const UpdateAccountSchema = z.object({
|
||||
.string({
|
||||
error: 'Last name is required',
|
||||
})
|
||||
.nonempty(),
|
||||
personalCode: z
|
||||
.string({
|
||||
error: 'Personal code is required',
|
||||
})
|
||||
.nonempty(),
|
||||
.nonempty({
|
||||
error: 'common:formFieldError.stringNonEmpty',
|
||||
}),
|
||||
personalCode: z.string().refine(
|
||||
(val) => {
|
||||
try {
|
||||
return new Isikukood(val).validate();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'common:formFieldError.invalidPersonalCode',
|
||||
},
|
||||
),
|
||||
email: z.string().email({
|
||||
message: 'Email is required',
|
||||
}),
|
||||
phone: z
|
||||
.string({
|
||||
error: 'Phone number is required',
|
||||
error: 'error:invalidPhone',
|
||||
})
|
||||
.nonempty(),
|
||||
.nonempty()
|
||||
.refine(
|
||||
(phone) => {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumber(phone);
|
||||
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'common:formFieldError.invalidPhoneNumber',
|
||||
}
|
||||
),
|
||||
city: z.string().optional(),
|
||||
weight: z
|
||||
.number({
|
||||
@@ -45,4 +69,34 @@ export const UpdateAccountSchema = z.object({
|
||||
userConsent: z.boolean().refine((val) => val === true, {
|
||||
message: 'Must be true',
|
||||
}),
|
||||
} as const;
|
||||
export const UpdateAccountSchemaServer = z.object({
|
||||
firstName: updateAccountSchema.firstName,
|
||||
lastName: updateAccountSchema.lastName,
|
||||
personalCode: updateAccountSchema.personalCode,
|
||||
email: updateAccountSchema.email,
|
||||
phone: updateAccountSchema.phone,
|
||||
city: updateAccountSchema.city,
|
||||
weight: updateAccountSchema.weight.nullable(),
|
||||
height: updateAccountSchema.height.nullable(),
|
||||
userConsent: updateAccountSchema.userConsent,
|
||||
});
|
||||
export const UpdateAccountSchemaClient = ({ isEmailUser }: { isEmailUser: boolean }) => z.object({
|
||||
firstName: updateAccountSchema.firstName,
|
||||
lastName: updateAccountSchema.lastName,
|
||||
personalCode: updateAccountSchema.personalCode,
|
||||
email: updateAccountSchema.email,
|
||||
phone: updateAccountSchema.phone,
|
||||
...(isEmailUser
|
||||
? {
|
||||
city: z.string().optional(),
|
||||
weight: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
}
|
||||
: {
|
||||
city: updateAccountSchema.city,
|
||||
weight: updateAccountSchema.weight,
|
||||
height: updateAccountSchema.height,
|
||||
}),
|
||||
userConsent: updateAccountSchema.userConsent,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { updateCustomer } from '@lib/data/customer';
|
||||
|
||||
import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
|
||||
@@ -10,8 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
|
||||
import { UpdateAccountSchema } from '../schemas/update-account.schema';
|
||||
import { UpdateAccountSchemaServer } from '../schemas/update-account.schema';
|
||||
|
||||
export const onUpdateAccount = enhanceAction(
|
||||
async (params: AccountSubmitData) => {
|
||||
@@ -28,22 +25,23 @@ export const onUpdateAccount = enhanceAction(
|
||||
console.warn('On update account error: ', err);
|
||||
}
|
||||
|
||||
await updateCustomer({
|
||||
first_name: params.firstName,
|
||||
last_name: params.lastName,
|
||||
phone: params.phone,
|
||||
});
|
||||
try {
|
||||
await updateCustomer({
|
||||
first_name: params.firstName,
|
||||
last_name: params.lastName,
|
||||
phone: params.phone,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update Medusa customer", e);
|
||||
}
|
||||
|
||||
const hasUnseenMembershipConfirmation =
|
||||
await api.hasUnseenMembershipConfirmation();
|
||||
|
||||
if (hasUnseenMembershipConfirmation) {
|
||||
redirect(pathsConfig.auth.membershipConfirmation);
|
||||
} else {
|
||||
redirect(pathsConfig.app.selectPackage);
|
||||
return {
|
||||
hasUnseenMembershipConfirmation,
|
||||
}
|
||||
},
|
||||
{
|
||||
schema: UpdateAccountSchema,
|
||||
schema: UpdateAccountSchemaServer,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { signOutAction } from '@/lib/actions/sign-out';
|
||||
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
|
||||
|
||||
import { BackButton } from '@kit/shared/components/back-button';
|
||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||
@@ -11,18 +10,36 @@ import { Trans } from '@kit/ui/trans';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { UpdateAccountForm } from './_components/update-account-form';
|
||||
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
|
||||
import { toTitleCase } from '~/lib/utils';
|
||||
|
||||
async function UpdateAccount() {
|
||||
const client = getSupabaseServerClient();
|
||||
const { account, user } = await loadCurrentUserAccount();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await client.auth.getUser();
|
||||
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
|
||||
const isEmailUser = user?.app_metadata?.provider === 'email';
|
||||
|
||||
if (!user) {
|
||||
redirect(pathsConfig.auth.signIn);
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
firstName: account?.name ? toTitleCase(account.name) : '',
|
||||
lastName: account?.last_name ? toTitleCase(account.last_name) : '',
|
||||
personalCode: account?.personal_code ?? '',
|
||||
email: (() => {
|
||||
if (isKeycloakUser) {
|
||||
return account?.email ?? '';
|
||||
}
|
||||
return account?.email ?? user?.email ?? '';
|
||||
})(),
|
||||
phone: account?.phone ?? '+372',
|
||||
city: account?.city ?? '',
|
||||
weight: account?.accountParams?.weight ?? 0,
|
||||
height: account?.accountParams?.height ?? 0,
|
||||
userConsent: account?.has_consent_personal_data ?? false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
||||
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
|
||||
@@ -34,7 +51,7 @@ async function UpdateAccount() {
|
||||
<p className="text-muted-foreground pt-1 text-sm">
|
||||
<Trans i18nKey={'account:updateAccount:description'} />
|
||||
</p>
|
||||
<UpdateAccountForm user={user} />
|
||||
<UpdateAccountForm defaultValues={defaultValues} isEmailUser={isEmailUser} />
|
||||
</div>
|
||||
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
@@ -22,14 +23,15 @@ export default async function AnalysisResultsPage({
|
||||
id: string;
|
||||
}>;
|
||||
}) {
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { id: analysisOrderId } = await params;
|
||||
|
||||
const { id: analysisResponseId } = await params;
|
||||
const [{ account }, analysisResponse] = await Promise.all([
|
||||
loadCurrentUserAccount(),
|
||||
loadUserAnalysis(Number(analysisOrderId)),
|
||||
]);
|
||||
|
||||
const analysisResponse = await loadUserAnalysis(Number(analysisResponseId));
|
||||
|
||||
if (!account?.id || !analysisResponse) {
|
||||
return null;
|
||||
if (!account?.id) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
await createPageViewLog({
|
||||
@@ -37,6 +39,19 @@ export default async function AnalysisResultsPage({
|
||||
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
|
||||
});
|
||||
|
||||
if (!analysisResponse) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={<Trans i18nKey="analysis-results:pageTitle" />}
|
||||
description={<Trans i18nKey="analysis-results:descriptionEmpty" />}
|
||||
/>
|
||||
<PageBody className="gap-4">
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
|
||||
@@ -28,9 +28,13 @@ function BookingPage() {
|
||||
return (
|
||||
<>
|
||||
<AppBreadcrumbs />
|
||||
<h3 className="mt-8">
|
||||
<HomeLayoutPageHeader
|
||||
title={<Trans i18nKey={'booking:title'} />}
|
||||
description={<Trans i18nKey={'booking:description'} />}
|
||||
/>
|
||||
<h4 className="mt-8">
|
||||
<Trans i18nKey="booking:noCategories" />
|
||||
</h3>
|
||||
</h4>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-
|
||||
import { listProductTypes } from "@lib/data/products";
|
||||
import { placeOrder, retrieveCart } from "@lib/data/cart";
|
||||
import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
|
||||
import { createOrder } from '~/lib/services/order.service';
|
||||
import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service';
|
||||
import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
|
||||
import { createNotificationsApi } from '@kit/notifications/api';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { AccountWithParams } from '@kit/accounts/api';
|
||||
import type { AccountWithParams } from '@kit/accounts/api';
|
||||
import type { StoreOrder } from '@medusajs/types';
|
||||
|
||||
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
|
||||
const ANALYSIS_TYPE_HANDLE = 'synlab-analysis';
|
||||
const MONTONIO_PAID_STATUS = 'PAID';
|
||||
|
||||
const env = () => z
|
||||
@@ -28,24 +30,27 @@ const env = () => z
|
||||
error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||
})
|
||||
.min(1),
|
||||
isEnabledDispatchOnMontonioCallback: z
|
||||
.boolean({
|
||||
error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required',
|
||||
}),
|
||||
})
|
||||
.parse({
|
||||
emailSender: process.env.EMAIL_SENDER,
|
||||
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
||||
isEnabledDispatchOnMontonioCallback: process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true',
|
||||
});
|
||||
|
||||
const sendEmail = async ({
|
||||
account,
|
||||
email,
|
||||
analysisPackageName,
|
||||
personName,
|
||||
partnerLocationName,
|
||||
language,
|
||||
}: {
|
||||
account: AccountWithParams,
|
||||
account: Pick<AccountWithParams, 'name' | 'id'>,
|
||||
email: string,
|
||||
analysisPackageName: string,
|
||||
personName: string,
|
||||
partnerLocationName: string,
|
||||
language: string,
|
||||
}) => {
|
||||
@@ -58,7 +63,7 @@ const sendEmail = async ({
|
||||
|
||||
const { html, subject } = await renderSynlabAnalysisPackageEmail({
|
||||
analysisPackageName,
|
||||
personName,
|
||||
personName: account.name,
|
||||
partnerLocationName,
|
||||
language,
|
||||
});
|
||||
@@ -83,9 +88,7 @@ const sendEmail = async ({
|
||||
}
|
||||
}
|
||||
|
||||
export async function processMontonioCallback(orderToken: string) {
|
||||
const { language } = await createI18nServerInstance();
|
||||
|
||||
async function decodeOrderToken(orderToken: string) {
|
||||
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
|
||||
|
||||
const decoded = jwt.verify(orderToken, secretKey, {
|
||||
@@ -96,54 +99,120 @@ export async function processMontonioCallback(orderToken: string) {
|
||||
throw new Error("Payment not successful");
|
||||
}
|
||||
|
||||
const account = await loadCurrentUserAccount();
|
||||
return decoded;
|
||||
}
|
||||
|
||||
async function getCartByOrderToken(decoded: MontonioOrderToken) {
|
||||
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
|
||||
if (!cartId) {
|
||||
throw new Error("Cart ID not found");
|
||||
}
|
||||
const cart = await retrieveCart(cartId);
|
||||
if (!cart) {
|
||||
throw new Error("Cart not found");
|
||||
}
|
||||
return cart;
|
||||
}
|
||||
|
||||
async function getOrderResultParameters(medusaOrder: StoreOrder) {
|
||||
const { productTypes } = await listProductTypes();
|
||||
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
|
||||
const analysisType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE);
|
||||
|
||||
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
|
||||
const analysisItems = medusaOrder.items?.filter(({ product_type_id }) => product_type_id === analysisType?.id);
|
||||
|
||||
return {
|
||||
medusaOrderId: medusaOrder.id,
|
||||
email: medusaOrder.email,
|
||||
analysisPackageOrder: analysisPackageOrderItem
|
||||
? {
|
||||
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
|
||||
analysisPackageName: analysisPackageOrderItem?.title ?? '',
|
||||
}
|
||||
: null,
|
||||
analysisItemsOrder: Array.isArray(analysisItems) && analysisItems.length > 0
|
||||
? analysisItems.map(({ product }) => ({
|
||||
analysisName: product?.title ?? '',
|
||||
analysisId: product?.metadata?.analysisIdOriginal as string ?? '',
|
||||
}))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendAnalysisPackageOrderEmail({
|
||||
account,
|
||||
email,
|
||||
analysisPackageOrder,
|
||||
}: {
|
||||
account: AccountWithParams,
|
||||
email: string,
|
||||
analysisPackageOrder: {
|
||||
partnerLocationName: string,
|
||||
analysisPackageName: string,
|
||||
},
|
||||
}) {
|
||||
const { language } = await createI18nServerInstance();
|
||||
const { analysisPackageName, partnerLocationName } = analysisPackageOrder;
|
||||
try {
|
||||
await sendEmail({
|
||||
account: { id: account.id, name: account.name },
|
||||
email,
|
||||
analysisPackageName,
|
||||
partnerLocationName,
|
||||
language,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send email", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function processMontonioCallback(orderToken: string) {
|
||||
const { account } = await loadCurrentUserAccount();
|
||||
if (!account) {
|
||||
throw new Error("Account not found in context");
|
||||
}
|
||||
|
||||
try {
|
||||
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
|
||||
if (!cartId) {
|
||||
throw new Error("Cart ID not found");
|
||||
}
|
||||
const decoded = await decodeOrderToken(orderToken);
|
||||
const cart = await getCartByOrderToken(decoded);
|
||||
|
||||
const cart = await retrieveCart(cartId);
|
||||
if (!cart) {
|
||||
throw new Error("Cart not found");
|
||||
}
|
||||
|
||||
|
||||
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
|
||||
const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false });
|
||||
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
||||
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
|
||||
|
||||
const { productTypes } = await listProductTypes();
|
||||
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
|
||||
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
|
||||
try {
|
||||
const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id });
|
||||
console.info(`Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`);
|
||||
return { success: true, orderId: existingAnalysisOrder.id };
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
const orderResult = {
|
||||
medusaOrderId: medusaOrder.id,
|
||||
email: medusaOrder.email,
|
||||
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
|
||||
analysisPackageName: analysisPackageOrderItem?.title ?? '',
|
||||
orderedAnalysisElements,
|
||||
};
|
||||
const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements });
|
||||
const orderResult = await getOrderResultParameters(medusaOrder);
|
||||
|
||||
const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult;
|
||||
const personName = account.name;
|
||||
const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult;
|
||||
|
||||
if (email && analysisPackageName) {
|
||||
try {
|
||||
await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language });
|
||||
} catch (error) {
|
||||
console.error("Failed to send email", error);
|
||||
if (email) {
|
||||
if (analysisPackageOrder) {
|
||||
await sendAnalysisPackageOrderEmail({ account, email, analysisPackageOrder });
|
||||
} else {
|
||||
console.info(`Order has no analysis package, skipping email.`);
|
||||
}
|
||||
|
||||
if (analysisItemsOrder) {
|
||||
// @TODO send email for separate analyses
|
||||
console.warn(`Order has analysis items, but no email template exists yet`);
|
||||
} else {
|
||||
console.info(`Order has no analysis items, skipping email.`);
|
||||
}
|
||||
} else {
|
||||
// @TODO send email for separate analyses
|
||||
console.error("Missing email or analysisPackageName", orderResult);
|
||||
console.error("Missing email to send order result email", orderResult);
|
||||
}
|
||||
|
||||
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
|
||||
if (env().isEnabledDispatchOnMontonioCallback) {
|
||||
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
|
||||
}
|
||||
|
||||
return { success: true, orderId };
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import Cart from '../../_components/cart';
|
||||
import { listProductTypes } from '@lib/data/products';
|
||||
import CartTimer from '../../_components/cart/cart-timer';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { t } = await createI18nServerInstance();
|
||||
@@ -17,9 +18,9 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CartPage() {
|
||||
async function CartPage() {
|
||||
const cart = await retrieveCart().catch((error) => {
|
||||
console.error(error);
|
||||
console.error("Failed to retrieve cart", error);
|
||||
return notFound();
|
||||
});
|
||||
|
||||
@@ -50,3 +51,5 @@ export default async function CartPage() {
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(CartPage);
|
||||
|
||||
@@ -18,7 +18,7 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
async function OrderAnalysisPage() {
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account } = await loadCurrentUserAccount();
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ async function OrderHealthAnalysisPage() {
|
||||
description={<Trans i18nKey={'order-health-analysis:description'} />}
|
||||
/>
|
||||
<PageBody>
|
||||
<h4 className="mt-8">
|
||||
<Trans i18nKey="booking:noCategories" />
|
||||
</h4>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { getOrder } from '~/lib/services/order.service';
|
||||
import { getAnalysisOrder } from '~/lib/services/order.service';
|
||||
import { retrieveOrder } from '@lib/data/orders';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
@@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null);
|
||||
const order = await getAnalysisOrder({ analysisOrderId: Number(params.orderId) }).catch(() => null);
|
||||
if (!order) {
|
||||
redirect(pathsConfig.app.myOrders);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { getOrder } from '~/lib/services/order.service';
|
||||
import { getAnalysisOrder } from '~/lib/services/order.service';
|
||||
import { retrieveOrder } from '@lib/data/orders';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
@@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null);
|
||||
const order = await getAnalysisOrder({ analysisOrderId: Number(params.orderId) }).catch(() => null);
|
||||
if (!order) {
|
||||
redirect(pathsConfig.app.myOrders);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,11 @@ async function OrdersPage() {
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
{analysisOrders.length === 0 && (
|
||||
<h5 className="mt-6">
|
||||
<Trans i18nKey="orders:noOrders" />
|
||||
</h5>
|
||||
)}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -26,8 +26,8 @@ export const generateMetadata = async () => {
|
||||
async function UserHomePage() {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const account = await loadCurrentUserAccount();
|
||||
const api = await createAccountsApi(client);
|
||||
const { account } = await loadCurrentUserAccount();
|
||||
const api = createAccountsApi(client);
|
||||
const bmiThresholds = await api.fetchBmiThresholds();
|
||||
|
||||
if (!account) {
|
||||
|
||||
@@ -55,11 +55,15 @@ export default function AnalysisLocation({ cart, synlabAnalyses }: { cart: Store
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white flex flex-col txt-medium gap-y-2">
|
||||
<div className="w-full h-full bg-white flex flex-col txt-medium gap-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans i18nKey={'cart:locations.description'} />
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => onSubmit(data))}
|
||||
className="w-full mb-2 flex gap-x-2"
|
||||
className="w-full mb-2 flex gap-x-2 flex-1"
|
||||
>
|
||||
<Select
|
||||
value={form.watch('locationId')}
|
||||
@@ -106,11 +110,6 @@ export default function AnalysisLocation({ cart, synlabAnalyses }: { cart: Store
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans i18nKey={'cart:locations.description'} />
|
||||
</p>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function CartItem({ item, currencyCode }: {
|
||||
|
||||
return (
|
||||
<TableRow className="w-full" data-testid="product-row">
|
||||
<TableCell className="text-left w-[100%] px-6">
|
||||
<TableCell className="text-left w-[100%] px-4 sm:px-6">
|
||||
<p
|
||||
className="txt-medium-plus text-ui-fg-base"
|
||||
data-testid="product-title"
|
||||
@@ -26,11 +26,11 @@ export default function CartItem({ item, currencyCode }: {
|
||||
</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-6">
|
||||
<TableCell className="px-4 sm:px-6">
|
||||
{item.quantity}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="min-w-[80px] px-6">
|
||||
<TableCell className="min-w-[80px] px-4 sm:px-6">
|
||||
{formatCurrency({
|
||||
value: item.unit_price,
|
||||
currencyCode,
|
||||
@@ -38,7 +38,7 @@ export default function CartItem({ item, currencyCode }: {
|
||||
})}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="min-w-[80px] px-6">
|
||||
<TableCell className="min-w-[80px] px-4 sm:px-6 text-right">
|
||||
{formatCurrency({
|
||||
value: item.total,
|
||||
currencyCode,
|
||||
@@ -46,7 +46,7 @@ export default function CartItem({ item, currencyCode }: {
|
||||
})}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right px-6">
|
||||
<TableCell className="text-right px-4 sm:px-6">
|
||||
<span className="flex gap-x-1 justify-end w-[60px]">
|
||||
<CartItemDelete id={item.id} />
|
||||
</span>
|
||||
|
||||
@@ -22,19 +22,19 @@ export default function CartItems({ cart, items, productColumnLabelKey }: {
|
||||
<Table className="rounded-lg border border-separate">
|
||||
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
||||
<TableRow>
|
||||
<TableHead className="px-6">
|
||||
<TableHead className="px-4 sm:px-6">
|
||||
<Trans i18nKey={productColumnLabelKey} />
|
||||
</TableHead>
|
||||
<TableHead className="px-6">
|
||||
<TableHead className="px-4 sm:px-6">
|
||||
<Trans i18nKey="cart:table.quantity" />
|
||||
</TableHead>
|
||||
<TableHead className="px-6 min-w-[100px]">
|
||||
<TableHead className="px-4 sm:px-6 min-w-[100px]">
|
||||
<Trans i18nKey="cart:table.price" />
|
||||
</TableHead>
|
||||
<TableHead className="px-6 min-w-[100px]">
|
||||
<TableHead className="px-4 sm:px-6 min-w-[100px] text-right">
|
||||
<Trans i18nKey="cart:table.total" />
|
||||
</TableHead>
|
||||
<TableHead className="px-6">
|
||||
<TableHead className="px-4 sm:px-6">
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
24
app/home/(user)/_components/cart/discount-code-actions.ts
Normal file
24
app/home/(user)/_components/cart/discount-code-actions.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
"use server"
|
||||
|
||||
import { applyPromotions } from "@lib/data/cart"
|
||||
|
||||
export async function addPromotionCodeAction(code: string) {
|
||||
try {
|
||||
await applyPromotions([code]);
|
||||
return { success: true, message: 'Discount code applied successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error applying promotion code:', error);
|
||||
return { success: false, message: 'Failed to apply discount code' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePromotionCodeAction(codeToRemove: string, appliedCodes: string[]) {
|
||||
try {
|
||||
const updatedCodes = appliedCodes.filter((appliedCode) => appliedCode !== codeToRemove);
|
||||
await applyPromotions(updatedCodes);
|
||||
return { success: true, message: 'Discount code removed successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error removing promotion code:', error);
|
||||
return { success: false, message: 'Failed to remove discount code' };
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import { Badge, Text } from "@medusajs/ui"
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import React, { useActionState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
|
||||
import { convertToLocale } from "@lib/util/money"
|
||||
import { StoreCart, StorePromotion } from "@medusajs/types"
|
||||
import Trash from "@modules/common/icons/trash"
|
||||
@@ -16,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { addPromotionCodeAction, removePromotionCodeAction } from "./discount-code-actions";
|
||||
|
||||
const DiscountCodeSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
@@ -31,42 +31,35 @@ export default function DiscountCode({ cart }: {
|
||||
const { promotions = [] } = cart;
|
||||
|
||||
const removePromotionCode = async (code: string) => {
|
||||
const validPromotions = promotions.filter(
|
||||
(promotion) => promotion.code !== code,
|
||||
)
|
||||
const appliedCodes = promotions
|
||||
.filter((p) => p.code !== undefined)
|
||||
.map((p) => p.code!)
|
||||
|
||||
await applyPromotions(
|
||||
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('cart:discountCode.removeSuccess'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('cart:discountCode.removeError'));
|
||||
},
|
||||
}
|
||||
)
|
||||
const loading = toast.loading(t('cart:discountCode.removeLoading'));
|
||||
|
||||
const result = await removePromotionCodeAction(code, appliedCodes)
|
||||
|
||||
toast.dismiss(loading);
|
||||
if (result.success) {
|
||||
toast.success(t('cart:discountCode.removeSuccess'));
|
||||
} else {
|
||||
toast.error(t('cart:discountCode.removeError'));
|
||||
}
|
||||
}
|
||||
|
||||
const addPromotionCode = async (code: string) => {
|
||||
const codes = promotions
|
||||
.filter((p) => p.code === undefined)
|
||||
.map((p) => p.code!)
|
||||
codes.push(code.toString())
|
||||
const loading = toast.loading(t('cart:discountCode.addLoading'));
|
||||
const result = await addPromotionCodeAction(code)
|
||||
|
||||
await applyPromotions(codes, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('cart:discountCode.addSuccess'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('cart:discountCode.addError'));
|
||||
},
|
||||
});
|
||||
|
||||
form.reset()
|
||||
toast.dismiss(loading);
|
||||
if (result.success) {
|
||||
toast.success(t('cart:discountCode.addSuccess'));
|
||||
form.reset()
|
||||
} else {
|
||||
toast.error(t('cart:discountCode.addError'));
|
||||
}
|
||||
}
|
||||
|
||||
const [message, formAction] = useActionState(submitPromotionForm, null)
|
||||
|
||||
const form = useForm<z.infer<typeof DiscountCodeSchema>>({
|
||||
defaultValues: {
|
||||
@@ -76,11 +69,15 @@ export default function DiscountCode({ cart }: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white flex flex-col txt-medium">
|
||||
<div className="w-full h-full bg-white flex flex-col txt-medium gap-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans i18nKey={'cart:discountCode.subtitle'} />
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
|
||||
className="w-full mb-2 flex gap-x-2 sm:flex-row flex-col gap-y-2"
|
||||
className="w-full mb-2 flex gap-x-2 sm:flex-row flex-col gap-y-2 flex-1"
|
||||
>
|
||||
<FormField
|
||||
name={'code'}
|
||||
@@ -96,14 +93,14 @@ export default function DiscountCode({ cart }: {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="h-full"
|
||||
className="h-min"
|
||||
>
|
||||
<Trans i18nKey={'cart:discountCode.apply'} />
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{promotions.length > 0 ? (
|
||||
{promotions.length > 0 && (
|
||||
<div className="w-full flex items-center mt-4">
|
||||
<div className="flex flex-col w-full gap-y-2">
|
||||
<p>
|
||||
@@ -117,12 +114,12 @@ export default function DiscountCode({ cart }: {
|
||||
className="flex items-center justify-between w-full max-w-full mb-2"
|
||||
data-testid="discount-row"
|
||||
>
|
||||
<Text className="flex gap-x-1 items-baseline txt-small-plus w-4/5 pr-1">
|
||||
<Text className="flex gap-x-1 items-baseline text-sm w-4/5 pr-1">
|
||||
<span className="truncate" data-testid="discount-code">
|
||||
<Badge
|
||||
color={promotion.is_automatic ? "green" : "grey"}
|
||||
size="small"
|
||||
className="px-4"
|
||||
className="px-4 text-sm"
|
||||
>
|
||||
{promotion.code}
|
||||
</Badge>{" "}
|
||||
@@ -135,7 +132,7 @@ export default function DiscountCode({ cart }: {
|
||||
"percentage"
|
||||
? `${promotion.application_method.value}%`
|
||||
: convertToLocale({
|
||||
amount: promotion.application_method.value,
|
||||
amount: Number(promotion.application_method.value),
|
||||
currency_code:
|
||||
promotion.application_method
|
||||
.currency_code,
|
||||
@@ -173,10 +170,6 @@ export default function DiscountCode({ cart }: {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans i18nKey={'cart:discountCode.subtitle'} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -78,14 +78,14 @@ export default function Cart({
|
||||
</div>
|
||||
{hasCartItems && (
|
||||
<>
|
||||
<div className="flex justify-end gap-x-4 px-6 pt-4">
|
||||
<div className="mr-[36px]">
|
||||
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 pt-2 sm:pt-4">
|
||||
<div className="w-full sm:w-auto sm:mr-[42px]">
|
||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
||||
<Trans i18nKey="cart:subtotal" />
|
||||
<Trans i18nKey="cart:order.subtotal" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="mr-[116px]">
|
||||
<p className="text-sm">
|
||||
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||
<p className="text-sm text-right">
|
||||
{formatCurrency({
|
||||
value: cart.subtotal,
|
||||
currencyCode: cart.currency_code,
|
||||
@@ -94,14 +94,14 @@ export default function Cart({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-x-4 px-6 py-2">
|
||||
<div className="mr-[36px]">
|
||||
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 py-2 sm:py-4">
|
||||
<div className="w-full sm:w-auto sm:mr-[42px]">
|
||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
||||
<Trans i18nKey="cart:promotionsTotal" />
|
||||
<Trans i18nKey="cart:order.promotionsTotal" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="mr-[116px]">
|
||||
<p className="text-sm">
|
||||
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||
<p className="text-sm text-right">
|
||||
{formatCurrency({
|
||||
value: cart.discount_total,
|
||||
currencyCode: cart.currency_code,
|
||||
@@ -110,14 +110,14 @@ export default function Cart({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-x-4 px-6">
|
||||
<div className="mr-[36px]">
|
||||
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6">
|
||||
<div className="w-full sm:w-auto sm:mr-[42px]">
|
||||
<p className="ml-0 font-bold text-sm">
|
||||
<Trans i18nKey="cart:total" />
|
||||
<Trans i18nKey="cart:order.total" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="mr-[116px]">
|
||||
<p className="text-sm">
|
||||
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||
<p className="text-sm text-right">
|
||||
{formatCurrency({
|
||||
value: cart.total,
|
||||
currencyCode: cart.currency_code,
|
||||
@@ -129,7 +129,7 @@ export default function Cart({
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex sm:flex-row flex-col gap-y-6 py-8 gap-x-4">
|
||||
<div className="flex sm:flex-row flex-col gap-y-6 py-4 sm:py-8 gap-x-4">
|
||||
{IS_DISCOUNT_SHOWN && (
|
||||
<Card
|
||||
className="flex flex-col justify-between w-full sm:w-1/2"
|
||||
@@ -139,7 +139,7 @@ export default function Cart({
|
||||
<Trans i18nKey="cart:discountCode.title" />
|
||||
</h5>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="h-full">
|
||||
<DiscountCode cart={{ ...cart }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -154,7 +154,7 @@ export default function Cart({
|
||||
<Trans i18nKey="cart:locations.title" />
|
||||
</h5>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="h-full">
|
||||
<AnalysisLocation cart={{ ...cart }} synlabAnalyses={synlabAnalyses} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -128,7 +128,7 @@ const ComparePackagesModal = async ({
|
||||
|
||||
return (
|
||||
<TableRow key={id}>
|
||||
<TableCell className="py-6">
|
||||
<TableCell className="py-6 sm:max-w-[30vw]">
|
||||
{title}{' '}
|
||||
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
|
||||
</TableCell>
|
||||
@@ -136,10 +136,10 @@ const ComparePackagesModal = async ({
|
||||
{isIncludedInStandard && <CheckWithBackground />}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="py-6">
|
||||
{(isIncludedInStandard || isIncludedInStandardPlus) && <CheckWithBackground />}
|
||||
{isIncludedInStandardPlus && <CheckWithBackground />}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="py-6">
|
||||
{(isIncludedInStandard || isIncludedInStandardPlus || isIncludedInPremium) && <CheckWithBackground />}
|
||||
{isIncludedInPremium && <CheckWithBackground />}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export default function DashboardCards() {
|
||||
return (
|
||||
<div className="flex gap-4 lg:px-4">
|
||||
<div className="flex gap-4">
|
||||
<Card
|
||||
variant="gradient-success"
|
||||
className="xs:w-1/2 sm:w-auto flex w-full flex-col justify-between"
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { getPersonParameters } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -30,7 +29,7 @@ import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
import { BmiCategory } from '~/lib/types/bmi';
|
||||
import {
|
||||
import PersonalCode, {
|
||||
bmiFromMetric,
|
||||
getBmiBackgroundColor,
|
||||
getBmiStatus,
|
||||
@@ -60,7 +59,7 @@ const cards = ({
|
||||
}) => [
|
||||
{
|
||||
title: 'dashboard:gender',
|
||||
description: gender ?? 'dashboard:male',
|
||||
description: gender ?? '-',
|
||||
icon: <User />,
|
||||
iconBg: 'bg-success',
|
||||
},
|
||||
@@ -84,7 +83,7 @@ const cards = ({
|
||||
},
|
||||
{
|
||||
title: 'dashboard:bmi',
|
||||
description: bmiFromMetric(weight || 0, height || 0).toString(),
|
||||
description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
|
||||
icon: <TrendingUp />,
|
||||
iconBg: getBmiBackgroundColor(bmiStatus),
|
||||
},
|
||||
@@ -145,21 +144,26 @@ export default function Dashboard({
|
||||
'id'
|
||||
>[];
|
||||
}) {
|
||||
const params = getPersonParameters(account.personal_code!);
|
||||
const bmiStatus = getBmiStatus(bmiThresholds, {
|
||||
age: params?.age || 0,
|
||||
height: account.accountParams?.height || 0,
|
||||
weight: account.accountParams?.weight || 0,
|
||||
});
|
||||
const height = account.accountParams?.height || 0;
|
||||
const weight = account.accountParams?.weight || 0;
|
||||
|
||||
let age: number = 0;
|
||||
let gender: { label: string; value: string } | null = null;
|
||||
try {
|
||||
({ age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse personal code", e);
|
||||
}
|
||||
const bmiStatus = getBmiStatus(bmiThresholds, { age, height, weight });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
||||
{cards({
|
||||
gender: params?.gender,
|
||||
age: params?.age,
|
||||
height: account.accountParams?.height,
|
||||
weight: account.accountParams?.weight,
|
||||
gender: gender?.label,
|
||||
age,
|
||||
height,
|
||||
weight,
|
||||
bmiStatus,
|
||||
smoking: account.accountParams?.isSmoker,
|
||||
}).map(
|
||||
|
||||
@@ -21,7 +21,6 @@ import { formatCurrency } from '@/packages/shared/src/utils';
|
||||
export type OrderAnalysisCard = Pick<
|
||||
StoreProduct, 'title' | 'description' | 'subtitle'
|
||||
> & {
|
||||
isAvailable: boolean;
|
||||
variant: { id: string };
|
||||
price: number | null;
|
||||
};
|
||||
@@ -58,13 +57,12 @@ export default function OrderAnalysesCards({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid 2xs:grid-cols-3 gap-6 mt-4">
|
||||
<div className="grid xs:grid-cols-3 gap-6 mt-4">
|
||||
{analyses.map(({
|
||||
title,
|
||||
variant,
|
||||
description,
|
||||
subtitle,
|
||||
isAvailable,
|
||||
price,
|
||||
}) => {
|
||||
const formattedPrice = typeof price === 'number'
|
||||
@@ -77,7 +75,7 @@ export default function OrderAnalysesCards({
|
||||
return (
|
||||
<Card
|
||||
key={title}
|
||||
variant={isAvailable ? "gradient-success" : "gradient-warning"}
|
||||
variant="gradient-success"
|
||||
className="flex flex-col justify-between"
|
||||
>
|
||||
<CardHeader className="flex-row">
|
||||
@@ -86,46 +84,44 @@ export default function OrderAnalysesCards({
|
||||
>
|
||||
<HeartPulse className="size-4 fill-green-500" />
|
||||
</div>
|
||||
{isAvailable && (
|
||||
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="px-2 text-black"
|
||||
onClick={() => handleSelect(variant.id)}
|
||||
>
|
||||
{variantAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="px-2 text-black"
|
||||
onClick={() => handleSelect(variant.id)}
|
||||
>
|
||||
{variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex flex-col items-start gap-2">
|
||||
<h5>
|
||||
{title}
|
||||
{description && (
|
||||
<>
|
||||
{' '}
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span>{formattedPrice}</span>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<CardFooter className="flex gap-2">
|
||||
<div className="flex flex-col items-start gap-2 flex-1">
|
||||
<h5>
|
||||
{title}
|
||||
{description && (
|
||||
<>
|
||||
{' '}
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span>{formattedPrice}</span>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</h5>
|
||||
{subtitle && (
|
||||
<CardDescription>
|
||||
{subtitle}
|
||||
</CardDescription>
|
||||
)}
|
||||
</h5>
|
||||
{isAvailable && subtitle && (
|
||||
<CardDescription>
|
||||
{subtitle}
|
||||
</CardDescription>
|
||||
)}
|
||||
{!isAvailable && (
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'order-analysis:analysisNotAvailable'} />
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 self-end text-sm">
|
||||
<span>{formattedPrice}</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function CartTotals({ medusaOrder }: {
|
||||
<div className="flex flex-col gap-y-2 txt-medium text-ui-fg-subtle ">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex gap-x-1 items-center">
|
||||
<Trans i18nKey="cart:orderConfirmed.subtotal" />
|
||||
<Trans i18nKey="cart:order.subtotal" />
|
||||
</span>
|
||||
<span data-testid="cart-subtotal" data-value={subtotal || 0}>
|
||||
{formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })}
|
||||
@@ -32,7 +32,7 @@ export default function CartTotals({ medusaOrder }: {
|
||||
</div>
|
||||
{!!discount_total && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span><Trans i18nKey="cart:orderConfirmed.discount" /></span>
|
||||
<span><Trans i18nKey="cart:order.promotionsTotal" /></span>
|
||||
<span
|
||||
className="text-ui-fg-interactive"
|
||||
data-testid="cart-discount"
|
||||
@@ -43,17 +43,17 @@ export default function CartTotals({ medusaOrder }: {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
{/* <div className="flex justify-between">
|
||||
<span className="flex gap-x-1 items-center ">
|
||||
<Trans i18nKey="cart:orderConfirmed.taxes" />
|
||||
</span>
|
||||
<span data-testid="cart-taxes" data-value={tax_total || 0}>
|
||||
{formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })}
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
{!!gift_card_total && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span><Trans i18nKey="cart:orderConfirmed.giftCard" /></span>
|
||||
<span><Trans i18nKey="cart:order.giftCard" /></span>
|
||||
<span
|
||||
className="text-ui-fg-interactive"
|
||||
data-testid="cart-gift-card-amount"
|
||||
@@ -67,7 +67,7 @@ export default function CartTotals({ medusaOrder }: {
|
||||
</div>
|
||||
<div className="h-px w-full border-b border-gray-200 my-4" />
|
||||
<div className="flex items-center justify-between text-ui-fg-base mb-2 txt-medium ">
|
||||
<span className="font-bold"><Trans i18nKey="cart:orderConfirmed.total" /></span>
|
||||
<span className="font-bold"><Trans i18nKey="cart:order.total" /></span>
|
||||
<span
|
||||
className="txt-xlarge-plus"
|
||||
data-testid="cart-total"
|
||||
|
||||
@@ -7,15 +7,23 @@ export default function OrderDetails({ order }: {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<span>
|
||||
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{" "}
|
||||
<div>
|
||||
<span className="font-bold">
|
||||
<Trans i18nKey="cart:orderConfirmed.orderNumber" />:{" "}
|
||||
</span>
|
||||
<span>
|
||||
{order.medusa_order_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-bold">
|
||||
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{" "}
|
||||
</span>
|
||||
<span>
|
||||
{formatDate(order.created_at, 'dd.MM.yyyy HH:mm')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-ui-fg-interactive">
|
||||
<Trans i18nKey="cart:orderConfirmed.orderNumber" />: <span data-testid="order-id">{order.medusa_order_id}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPageViewLog, PageViewAction } from "~/lib/services/audit/pageView
|
||||
import { loadCurrentUserAccount } from "../../_lib/server/load-user-account";
|
||||
|
||||
export async function logAnalysisResultsNavigateAction(analysisOrderId: string) {
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account } = await loadCurrentUserAccount();
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ async function analysesLoader() {
|
||||
const categoryProducts = category
|
||||
? await listProducts({
|
||||
countryCode,
|
||||
queryParams: { limit: 100, category_id: category.id },
|
||||
queryParams: { limit: 100, category_id: category.id, order: 'title' },
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -51,8 +51,10 @@ async function analysesLoader() {
|
||||
|
||||
return {
|
||||
analyses:
|
||||
categoryProducts?.response.products.map<OrderAnalysisCard>(
|
||||
({ title, description, subtitle, variants, status, metadata }) => {
|
||||
categoryProducts?.response.products
|
||||
.filter(({ status, metadata }) => status === 'published' && !!metadata?.analysisIdOriginal)
|
||||
.map<OrderAnalysisCard>(
|
||||
({ title, description, subtitle, variants }) => {
|
||||
const variant = variants![0]!;
|
||||
return {
|
||||
title,
|
||||
@@ -61,8 +63,6 @@ async function analysesLoader() {
|
||||
variant: {
|
||||
id: variant.id,
|
||||
},
|
||||
isAvailable:
|
||||
status === 'published' && !!metadata?.analysisIdOriginal,
|
||||
price: variant.calculated_price?.calculated_amount ?? null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cache } from 'react';
|
||||
import Isikukood, { Gender } from 'isikukood';
|
||||
|
||||
import { listProductTypes, listProducts } from "@lib/data/products";
|
||||
import { listRegions } from '@lib/data/regions';
|
||||
@@ -8,6 +7,7 @@ import type { StoreProduct } from '@medusajs/types';
|
||||
import { loadCurrentUserAccount } from './load-user-account';
|
||||
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
|
||||
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
|
||||
import PersonalCode from '~/lib/utils';
|
||||
|
||||
async function countryCodesLoader() {
|
||||
const countryCodes = await listRegions().then((regions) =>
|
||||
@@ -32,27 +32,8 @@ function userSpecificVariantLoader({
|
||||
if (!personalCode) {
|
||||
throw new Error('Personal code not found');
|
||||
}
|
||||
const parsed = new Isikukood(personalCode);
|
||||
const ageRange = (() => {
|
||||
const age = parsed.getAge();
|
||||
if (age >= 18 && age <= 29) {
|
||||
return '18-29';
|
||||
}
|
||||
if (age >= 30 && age <= 39) {
|
||||
return '30-39';
|
||||
}
|
||||
if (age >= 40 && age <= 49) {
|
||||
return '40-49';
|
||||
}
|
||||
if (age >= 50 && age <= 59) {
|
||||
return '50-59';
|
||||
}
|
||||
if (age >= 60) {
|
||||
return '60';
|
||||
}
|
||||
throw new Error('Age range not supported');
|
||||
})();
|
||||
const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F';
|
||||
|
||||
const { ageRange, gender: { value: gender } } = PersonalCode.parsePersonalCode(personalCode);
|
||||
|
||||
return ({
|
||||
product,
|
||||
@@ -89,6 +70,7 @@ async function analysisPackageElementsLoader({
|
||||
queryParams: {
|
||||
id: analysisElementMedusaProductIds,
|
||||
limit: 100,
|
||||
order: "title",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -140,8 +122,9 @@ async function analysisPackagesWithVariantLoader({
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
variant,
|
||||
variantId: variant.id,
|
||||
nrOfAnalyses: getAnalysisElementMedusaProductIds([product]).length,
|
||||
nrOfAnalyses: getAnalysisElementMedusaProductIds([{ ...product, variant }]).length,
|
||||
price: variant.calculated_price?.calculated_amount ?? 0,
|
||||
title: product.title,
|
||||
subtitle: product.subtitle,
|
||||
@@ -158,7 +141,7 @@ async function analysisPackagesWithVariantLoader({
|
||||
}
|
||||
|
||||
async function analysisPackagesLoader() {
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account } = await loadCurrentUserAccount();
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
@@ -16,14 +16,17 @@ export const loadUserAccount = cache(accountLoader);
|
||||
|
||||
export async function loadCurrentUserAccount() {
|
||||
const user = await requireUserInServerComponent();
|
||||
return user?.identities?.[0]?.id
|
||||
? await loadUserAccount(user?.identities?.[0]?.id)
|
||||
: null;
|
||||
const userId = user?.id;
|
||||
if (!userId) {
|
||||
return { account: null, user: null };
|
||||
}
|
||||
const account = await loadUserAccount(userId);
|
||||
return { account, user };
|
||||
}
|
||||
|
||||
async function accountLoader(accountId: string) {
|
||||
async function accountLoader(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
return api.getAccount(accountId);
|
||||
return api.getPersonalAccountByUserId(userId);
|
||||
}
|
||||
|
||||
@@ -28,20 +28,15 @@ async function workspaceLoader() {
|
||||
|
||||
const workspacePromise = api.getAccountWorkspace();
|
||||
|
||||
// TODO!: remove before deploy to prod
|
||||
const tempAccountsPromise = () => api.loadTempUserAccounts();
|
||||
|
||||
const [accounts, workspace, user, tempVisibleAccounts] = await Promise.all([
|
||||
const [accounts, workspace, user] = await Promise.all([
|
||||
accountsPromise(),
|
||||
workspacePromise,
|
||||
requireUserInServerComponent(),
|
||||
tempAccountsPromise(),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
workspace,
|
||||
user,
|
||||
tempVisibleAccounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Trans } from 'react-i18next';
|
||||
import { AccountWithParams } from '@kit/accounts/api';
|
||||
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@kit/ui/select';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Switch } from '@kit/ui/switch';
|
||||
|
||||
import {
|
||||
AccountSettings,
|
||||
@@ -131,7 +129,11 @@ export default function AccountSettingsForm({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
placeholder="cm"
|
||||
type="number"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@@ -150,7 +152,11 @@ export default function AccountSettingsForm({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
placeholder="kg"
|
||||
type="number"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -12,8 +12,8 @@ export const accountSettingsSchema = z.object({
|
||||
email: z.email({ error: 'error:invalidEmail' }).nullable(),
|
||||
phone: z.e164({ error: 'error:invalidPhone' }),
|
||||
accountParams: z.object({
|
||||
height: z.coerce.number({ error: 'error:invalidNumber' }),
|
||||
weight: z.coerce.number({ error: 'error:invalidNumber' }),
|
||||
height: z.coerce.number({ error: 'error:invalidNumber' }).gt(0),
|
||||
weight: z.coerce.number({ error: 'error:invalidNumber' }).gt(0),
|
||||
isSmoker: z.boolean().optional().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
async function PersonalAccountSettingsPage() {
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account } = await loadCurrentUserAccount();
|
||||
return (
|
||||
<PageBody>
|
||||
<div className="mx-auto w-full bg-white p-6">
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { CardTitle } from '@kit/ui/card';
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
|
||||
import AccountPreferencesForm from '../_components/account-preferences-form';
|
||||
import SettingsSectionHeader from '../_components/settings-section-header';
|
||||
|
||||
export default async function PreferencesPage() {
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account } = await loadCurrentUserAccount();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full bg-white p-6">
|
||||
@@ -16,7 +12,6 @@ export default async function PreferencesPage() {
|
||||
titleKey="account:preferencesTabLabel"
|
||||
descriptionKey="account:preferencesTabDescription"
|
||||
/>
|
||||
|
||||
<AccountPreferencesForm account={account} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,11 +31,11 @@ export const getAccountHealthDetailsFields = (
|
||||
>[],
|
||||
members: Database['medreport']['Functions']['get_account_members']['Returns'],
|
||||
): AccountHealthDetailsField[] => {
|
||||
const avarageWeight =
|
||||
const averageWeight =
|
||||
memberParams.reduce((sum, r) => sum + r.weight!, 0) / memberParams.length;
|
||||
const avarageHeight =
|
||||
const averageHeight =
|
||||
memberParams.reduce((sum, r) => sum + r.height!, 0) / memberParams.length;
|
||||
const avarageAge =
|
||||
const averageAge =
|
||||
members.reduce((sum, r) => {
|
||||
const person = new Isikukood(r.personal_code);
|
||||
return sum + person.getAge();
|
||||
@@ -48,11 +48,11 @@ export const getAccountHealthDetailsFields = (
|
||||
const person = new Isikukood(r.personal_code);
|
||||
return person.getGender() === 'female';
|
||||
}).length;
|
||||
const averageBMI = bmiFromMetric(avarageWeight, avarageHeight);
|
||||
const averageBMI = bmiFromMetric(averageWeight, averageHeight);
|
||||
const bmiStatus = getBmiStatus(bmiThresholds, {
|
||||
age: avarageAge,
|
||||
height: avarageHeight,
|
||||
weight: avarageWeight,
|
||||
age: averageAge,
|
||||
height: averageHeight,
|
||||
weight: averageWeight,
|
||||
});
|
||||
const malePercentage = members.length
|
||||
? (numberOfMaleMembers / members.length) * 100
|
||||
@@ -76,7 +76,7 @@ export const getAccountHealthDetailsFields = (
|
||||
},
|
||||
{
|
||||
title: 'teams:healthDetails.avgAge',
|
||||
value: avarageAge.toFixed(0),
|
||||
value: averageAge.toFixed(0),
|
||||
Icon: Clock,
|
||||
iconBg: 'bg-success',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component';
|
||||
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
|
||||
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
|
||||
|
||||
@@ -12,8 +11,7 @@ export default async function HomeLayout({
|
||||
}) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const user = await requireUserInServerComponent();
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account, user } = await loadCurrentUserAccount();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
const hasAccountTeamMembership = await api.hasAccountTeamMembership(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||
import { Scale } from 'lucide-react';
|
||||
@@ -27,6 +28,10 @@ async function SelectPackagePage() {
|
||||
const { analysisPackageElements, analysisPackages, countryCode } =
|
||||
await loadAnalysisPackages();
|
||||
|
||||
if (analysisPackageElements.length === 0) {
|
||||
return redirect(pathsConfig.app.home);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
|
||||
<MedReportLogo />
|
||||
|
||||
@@ -3,9 +3,26 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { medusaLogout } from '@lib/data/customer';
|
||||
|
||||
export const signOutAction = async () => {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
const client = await createClient();
|
||||
|
||||
try {
|
||||
try {
|
||||
await medusaLogout();
|
||||
} 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;
|
||||
}
|
||||
|
||||
return redirect('/');
|
||||
};
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||
export const DATE_FORMAT = "yyyy-mm-dd";
|
||||
export const DATE_FORMAT = "yyyy-MM-dd";
|
||||
|
||||
@@ -37,6 +37,7 @@ export const defaultI18nNamespaces = [
|
||||
'booking',
|
||||
'order-analysis-package',
|
||||
'order-analysis',
|
||||
'order-health-analysis',
|
||||
'cart',
|
||||
'orders',
|
||||
'analysis-results',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Tables } from '@/packages/supabase/src/database.types';
|
||||
|
||||
import { AccountWithParams } from '@kit/accounts/api';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
@@ -26,6 +25,19 @@ export async function getAccount(id: string): Promise<AccountWithMemberships> {
|
||||
return data as unknown as AccountWithMemberships;
|
||||
}
|
||||
|
||||
export async function getUserContactAdmin(userId: string) {
|
||||
const { data } = await getSupabaseServerAdminClient()
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.select('name, last_name, email, preferred_locale')
|
||||
.eq('primary_owner_user_id', userId)
|
||||
.eq('is_personal_account', true)
|
||||
.single()
|
||||
.throwOnError();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAccountAdmin({
|
||||
primaryOwnerUserId,
|
||||
}: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Tables } from '@/packages/supabase/src/database.types';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import type { IUuringElement } from "./medipost.types";
|
||||
|
||||
type AnalysesWithGroupsAndElements = ({
|
||||
export type AnalysesWithGroupsAndElements = ({
|
||||
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
|
||||
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
|
||||
};
|
||||
@@ -105,7 +105,13 @@ export const createMedusaSyncSuccessEntry = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAnalyses({ ids, originalIds }: { ids?: number[], originalIds?: string[] }): Promise<AnalysesWithGroupsAndElements> {
|
||||
export async function getAnalyses({
|
||||
ids,
|
||||
originalIds,
|
||||
}: {
|
||||
ids?: number[];
|
||||
originalIds?: string[];
|
||||
}): Promise<AnalysesWithGroupsAndElements> {
|
||||
const query = getSupabaseServerAdminClient()
|
||||
.schema('medreport')
|
||||
.from('analyses')
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
export enum NotificationAction {
|
||||
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
|
||||
NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
|
||||
PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
|
||||
DOCTOR_NEW_JOBS = 'DOCTOR_NEW_JOBS',
|
||||
DOCTOR_PATIENT_RESULTS_RECEIVED = 'DOCTOR_PATIENT_RESULTS_RECEIVED',
|
||||
PATIENT_DOCTOR_FEEDBACK_RECEIVED = 'PATIENT_DOCTOR_FEEDBACK_RECEIVED',
|
||||
PATIENT_ORDER_PROCESSING = 'PATIENT_ORDER_PROCESSING',
|
||||
PATIENT_FIRST_RESULTS_RECEIVED = 'PATIENT_FIRST_RESULTS_RECEIVED',
|
||||
PATIENT_FULL_RESULTS_RECEIVED = 'PATIENT_FULL_RESULTS_RECEIVED',
|
||||
}
|
||||
|
||||
export const createNotificationLog = async ({
|
||||
|
||||
@@ -37,7 +37,6 @@ export const createPageViewLog = async ({
|
||||
account_id: accountId,
|
||||
action,
|
||||
changed_by: user.id,
|
||||
extra_data: extraData,
|
||||
})
|
||||
.throwOnError();
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,7 +13,7 @@ type EmailTemplate = {
|
||||
subject: string;
|
||||
};
|
||||
|
||||
type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
|
||||
export type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
|
||||
|
||||
export const sendEmailFromTemplate = async <T>(
|
||||
renderer: EmailRenderer<T>,
|
||||
|
||||
@@ -5,23 +5,11 @@ import {
|
||||
createClient as createCustomClient,
|
||||
} from '@supabase/supabase-js';
|
||||
|
||||
import {
|
||||
getAnalysisGroup,
|
||||
getClientInstitution,
|
||||
getClientPerson,
|
||||
getConfidentiality,
|
||||
getOrderEnteredPerson,
|
||||
getPais,
|
||||
getPatient,
|
||||
getProviderInstitution,
|
||||
getSpecimen,
|
||||
} from '@/lib/templates/medipost-order';
|
||||
import { SyncStatus } from '@/lib/types/audit';
|
||||
import {
|
||||
AnalysisOrderStatus,
|
||||
GetMessageListResponse,
|
||||
IMedipostResponseXMLBase,
|
||||
MaterjalideGrupp,
|
||||
MedipostAction,
|
||||
MedipostOrderResponse,
|
||||
MedipostPublicMessageResponse,
|
||||
@@ -32,12 +20,11 @@ import {
|
||||
import { toArray } from '@/lib/utils';
|
||||
import axios from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { uniqBy } from 'lodash';
|
||||
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
import { createAnalysisGroup } from './analysis-group.service';
|
||||
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
|
||||
import { getOrder, updateOrderStatus } from './order.service';
|
||||
import { getAnalysisOrder, updateAnalysisOrderStatus } from './order.service';
|
||||
import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
|
||||
import { getAnalyses } from './analyses.service';
|
||||
import { getAccountAdmin } from './account.service';
|
||||
@@ -47,6 +34,7 @@ import { listRegions } from '@lib/data/regions';
|
||||
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
|
||||
import { MedipostValidationError } from './medipost/MedipostValidationError';
|
||||
import { logMedipostDispatch } from './audit.service';
|
||||
import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service';
|
||||
|
||||
const BASE_URL = process.env.MEDIPOST_URL!;
|
||||
const USER = process.env.MEDIPOST_USER!;
|
||||
@@ -206,12 +194,13 @@ export async function readPrivateMessageResponse({
|
||||
excludedMessageIds,
|
||||
}: {
|
||||
excludedMessageIds: string[];
|
||||
}): Promise<{ messageId: string | null; hasAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined }> {
|
||||
}): Promise<{ messageId: string | null; hasAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined; analysisOrderId: number | undefined }> {
|
||||
let messageId: string | null = null;
|
||||
let hasAnalysisResponse = false;
|
||||
let hasPartialAnalysisResponse = false;
|
||||
let hasFullAnalysisResponse = false;
|
||||
let medusaOrderId: string | undefined = undefined;
|
||||
let analysisOrderId: number | undefined = undefined;
|
||||
|
||||
try {
|
||||
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
|
||||
@@ -224,6 +213,7 @@ export async function readPrivateMessageResponse({
|
||||
hasPartialAnalysisResponse: false,
|
||||
hasFullAnalysisResponse: false,
|
||||
medusaOrderId: undefined,
|
||||
analysisOrderId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,16 +222,15 @@ export async function readPrivateMessageResponse({
|
||||
);
|
||||
|
||||
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
|
||||
medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
|
||||
analysisOrderId = Number(privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId);
|
||||
|
||||
const hasInvalidOrderId = !medusaOrderId || !medusaOrderId.toString().startsWith('order_');
|
||||
const hasInvalidOrderId = isNaN(analysisOrderId)
|
||||
|
||||
if (hasInvalidOrderId || !messageResponse) {
|
||||
await createMedipostActionLog({
|
||||
action: 'sync_analysis_results_from_medipost',
|
||||
xml: privateMessageXml,
|
||||
hasAnalysisResults: false,
|
||||
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
|
||||
});
|
||||
return {
|
||||
messageId,
|
||||
@@ -249,12 +238,16 @@ export async function readPrivateMessageResponse({
|
||||
hasPartialAnalysisResponse: false,
|
||||
hasFullAnalysisResponse: false,
|
||||
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
|
||||
analysisOrderId: hasInvalidOrderId ? undefined : analysisOrderId
|
||||
};
|
||||
}
|
||||
|
||||
const analysisOrder = await getAnalysisOrder({ analysisOrderId: analysisOrderId })
|
||||
medusaOrderId = analysisOrder.medusa_order_id;
|
||||
|
||||
let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
|
||||
try {
|
||||
order = await getOrder({ medusaOrderId });
|
||||
order = await getAnalysisOrder({ medusaOrderId });
|
||||
} catch (e) {
|
||||
await deletePrivateMessage(privateMessage.messageId);
|
||||
throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`);
|
||||
@@ -263,11 +256,11 @@ export async function readPrivateMessageResponse({
|
||||
const status = await syncPrivateMessage({ messageResponse, order });
|
||||
|
||||
if (status.isPartial) {
|
||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
|
||||
await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
|
||||
hasAnalysisResponse = true;
|
||||
hasPartialAnalysisResponse = true;
|
||||
} else if (status.isCompleted) {
|
||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' });
|
||||
await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' });
|
||||
await deletePrivateMessage(privateMessage.messageId);
|
||||
hasAnalysisResponse = true;
|
||||
hasFullAnalysisResponse = true;
|
||||
@@ -276,7 +269,7 @@ export async function readPrivateMessageResponse({
|
||||
console.warn(`Failed to process private message id=${messageId}, message=${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId };
|
||||
return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId, analysisOrderId };
|
||||
}
|
||||
|
||||
async function saveAnalysisGroup(
|
||||
@@ -451,122 +444,6 @@ export async function syncPublicMessage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function composeOrderXML({
|
||||
person,
|
||||
orderedAnalysisElementsIds,
|
||||
orderedAnalysesIds,
|
||||
orderId,
|
||||
orderCreatedAt,
|
||||
comment,
|
||||
}: {
|
||||
person: {
|
||||
idCode: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
};
|
||||
orderedAnalysisElementsIds: number[];
|
||||
orderedAnalysesIds: number[];
|
||||
orderId: string;
|
||||
orderCreatedAt: Date;
|
||||
comment?: string;
|
||||
}) {
|
||||
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
|
||||
if (analysisElements.length !== orderedAnalysisElementsIds.length) {
|
||||
throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`);
|
||||
}
|
||||
|
||||
const analyses = await getAnalyses({ ids: orderedAnalysesIds });
|
||||
if (analyses.length !== orderedAnalysesIds.length) {
|
||||
throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`);
|
||||
}
|
||||
|
||||
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
|
||||
uniqBy(
|
||||
(
|
||||
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
|
||||
[]
|
||||
).concat(
|
||||
analyses?.flatMap(
|
||||
({ analysis_elements }) => analysis_elements.analysis_groups,
|
||||
) ?? [],
|
||||
),
|
||||
'id',
|
||||
);
|
||||
|
||||
const specimenSection = [];
|
||||
const analysisSection = [];
|
||||
let order = 1;
|
||||
for (const currentGroup of analysisGroups) {
|
||||
let relatedAnalysisElement = analysisElements?.find(
|
||||
(element) => element.analysis_groups.id === currentGroup.id,
|
||||
);
|
||||
const relatedAnalyses = analyses?.filter((analysis) => {
|
||||
return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
|
||||
});
|
||||
|
||||
if (!relatedAnalysisElement) {
|
||||
relatedAnalysisElement = relatedAnalyses?.find(
|
||||
(relatedAnalysis) =>
|
||||
relatedAnalysis.analysis_elements.analysis_groups.id ===
|
||||
currentGroup.id,
|
||||
)?.analysis_elements;
|
||||
}
|
||||
|
||||
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
|
||||
throw new Error(
|
||||
`Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
|
||||
const materials = toArray(group.Materjal);
|
||||
const specimenXml = materials.flatMap(
|
||||
({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => {
|
||||
return toArray(Konteiner).map((container) =>
|
||||
getSpecimen(
|
||||
MaterjaliTyypOID,
|
||||
MaterjaliTyyp,
|
||||
MaterjaliNimi,
|
||||
order,
|
||||
container.ProovinouKoodOID,
|
||||
container.ProovinouKood,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
specimenSection.push(...specimenXml);
|
||||
}
|
||||
|
||||
const groupXml = getAnalysisGroup(
|
||||
currentGroup.original_id,
|
||||
currentGroup.name,
|
||||
order,
|
||||
relatedAnalysisElement,
|
||||
);
|
||||
order++;
|
||||
analysisSection.push(groupXml);
|
||||
}
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
|
||||
${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
|
||||
<Tellimus cito="EI">
|
||||
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
||||
${getClientInstitution()}
|
||||
${getProviderInstitution()}
|
||||
${getClientPerson()}
|
||||
${getOrderEnteredPerson()}
|
||||
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
|
||||
${getPatient(person)}
|
||||
${getConfidentiality()}
|
||||
${specimenSection.join('')}
|
||||
${analysisSection?.join('')}
|
||||
</Tellimus>
|
||||
</Saadetis>`;
|
||||
}
|
||||
|
||||
function getLatestMessage({
|
||||
messages,
|
||||
excludedMessageIds,
|
||||
@@ -694,7 +571,7 @@ async function syncPrivateMessage({
|
||||
);
|
||||
}
|
||||
|
||||
const { data: allOrderResponseElements} = await supabase
|
||||
const { data: allOrderResponseElements } = await supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_response_elements')
|
||||
.select('*')
|
||||
@@ -714,21 +591,37 @@ export async function sendOrderToMedipost({
|
||||
orderedAnalysisElements,
|
||||
}: {
|
||||
medusaOrderId: string;
|
||||
orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
|
||||
orderedAnalysisElements: OrderedAnalysisElement[];
|
||||
}) {
|
||||
const medreportOrder = await getOrder({ medusaOrderId });
|
||||
const medreportOrder = await getAnalysisOrder({ medusaOrderId });
|
||||
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
|
||||
|
||||
const orderedAnalysesIds = orderedAnalysisElements
|
||||
.map(({ analysisId }) => analysisId)
|
||||
.filter(Boolean) as number[];
|
||||
const orderedAnalysisElementsIds = orderedAnalysisElements
|
||||
.map(({ analysisElementId }) => analysisElementId)
|
||||
.filter(Boolean) as number[];
|
||||
|
||||
const analyses = await getAnalyses({ ids: orderedAnalysesIds });
|
||||
if (analyses.length !== orderedAnalysesIds.length) {
|
||||
throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`);
|
||||
}
|
||||
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
|
||||
if (analysisElements.length !== orderedAnalysisElementsIds.length) {
|
||||
throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`);
|
||||
}
|
||||
|
||||
const orderXml = await composeOrderXML({
|
||||
analyses,
|
||||
analysisElements,
|
||||
person: {
|
||||
idCode: account.personal_code!,
|
||||
firstName: account.name ?? '',
|
||||
lastName: account.last_name ?? '',
|
||||
phone: account.phone ?? '',
|
||||
},
|
||||
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||
orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||
orderId: medusaOrderId,
|
||||
orderId: medreportOrder.id,
|
||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||
comment: '',
|
||||
});
|
||||
@@ -780,7 +673,7 @@ export async function sendOrderToMedipost({
|
||||
hasAnalysisResults: false,
|
||||
medusaOrderId,
|
||||
});
|
||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
||||
await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
||||
}
|
||||
|
||||
export async function getOrderedAnalysisIds({
|
||||
@@ -826,7 +719,12 @@ export async function getOrderedAnalysisIds({
|
||||
throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`);
|
||||
}
|
||||
|
||||
const ids = getAnalysisElementMedusaProductIds(orderedPackagesProducts);
|
||||
const ids = getAnalysisElementMedusaProductIds(
|
||||
orderedPackagesProducts.map(({ id, metadata }) => ({
|
||||
metadata,
|
||||
variant: orderedPackages.find(({ product }) => product?.id === id)?.variant,
|
||||
})),
|
||||
);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -867,10 +765,10 @@ export async function createMedipostActionLog({
|
||||
hasError = false,
|
||||
}: {
|
||||
action:
|
||||
| 'send_order_to_medipost'
|
||||
| 'sync_analysis_results_from_medipost'
|
||||
| 'send_fake_analysis_results_to_medipost'
|
||||
| 'send_analysis_results_to_medipost';
|
||||
| 'send_order_to_medipost'
|
||||
| 'sync_analysis_results_from_medipost'
|
||||
| 'send_fake_analysis_results_to_medipost'
|
||||
| 'send_analysis_results_to_medipost';
|
||||
xml: string;
|
||||
hasAnalysisResults?: boolean;
|
||||
medusaOrderId?: string | null;
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function composeOrderTestResponseXML({
|
||||
};
|
||||
orderedAnalysisElementsIds: number[];
|
||||
orderedAnalysesIds: number[];
|
||||
orderId: string;
|
||||
orderId: number;
|
||||
orderCreatedAt: Date;
|
||||
}) {
|
||||
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
|
||||
@@ -100,7 +100,7 @@ export async function composeOrderTestResponseXML({
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
|
||||
${getPais(USER, RECIPIENT, orderCreatedAt, orderId, "AL")}
|
||||
${getPais(USER, RECIPIENT, orderId, "AL")}
|
||||
<Vastus>
|
||||
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
||||
${getClientInstitution({ index: 1 })}
|
||||
|
||||
201
lib/services/medipostXML.service.ts
Normal file
201
lib/services/medipostXML.service.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
getAnalysisGroup,
|
||||
getClientInstitution,
|
||||
getClientPerson,
|
||||
getConfidentiality,
|
||||
getOrderEnteredPerson,
|
||||
getPais,
|
||||
getPatient,
|
||||
getProviderInstitution,
|
||||
getSpecimen,
|
||||
} from '@/lib/templates/medipost-order';
|
||||
import {
|
||||
MaterjalideGrupp,
|
||||
} from '@/lib/types/medipost';
|
||||
import { toArray } from '@/lib/utils';
|
||||
import { uniqBy } from 'lodash';
|
||||
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
import { AnalysisElement } from './analysis-element.service';
|
||||
import { AnalysesWithGroupsAndElements } from './analyses.service';
|
||||
|
||||
const USER = process.env.MEDIPOST_USER!;
|
||||
const RECIPIENT = process.env.MEDIPOST_RECIPIENT!;
|
||||
|
||||
export type OrderedAnalysisElement = {
|
||||
analysisElementId?: number;
|
||||
analysisId?: number;
|
||||
}
|
||||
|
||||
export async function composeOrderXML({
|
||||
analyses,
|
||||
analysisElements,
|
||||
person,
|
||||
orderId,
|
||||
orderCreatedAt,
|
||||
comment,
|
||||
}: {
|
||||
analyses: AnalysesWithGroupsAndElements;
|
||||
analysisElements: AnalysisElement[];
|
||||
person: {
|
||||
idCode: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
};
|
||||
orderId: number;
|
||||
orderCreatedAt: Date;
|
||||
comment?: string;
|
||||
}) {
|
||||
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
|
||||
uniqBy(
|
||||
(
|
||||
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
|
||||
[]
|
||||
).concat(
|
||||
analyses?.flatMap(
|
||||
({ analysis_elements }) => analysis_elements.analysis_groups,
|
||||
) ?? [],
|
||||
),
|
||||
'id',
|
||||
);
|
||||
|
||||
// First, collect all unique materials across all analysis groups
|
||||
const uniqueMaterials = new Map<string, {
|
||||
MaterjaliTyypOID: string;
|
||||
MaterjaliTyyp: string;
|
||||
MaterjaliNimi: string;
|
||||
ProovinouKoodOID?: string;
|
||||
ProovinouKood?: string;
|
||||
order: number;
|
||||
}>();
|
||||
|
||||
let specimenOrder = 1;
|
||||
|
||||
// Collect all materials from all analysis groups
|
||||
for (const currentGroup of analysisGroups) {
|
||||
let relatedAnalysisElement = analysisElements?.find(
|
||||
(element) => element.analysis_groups.id === currentGroup.id,
|
||||
);
|
||||
const relatedAnalyses = analyses?.filter((analysis) => {
|
||||
return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
|
||||
});
|
||||
|
||||
if (!relatedAnalysisElement) {
|
||||
relatedAnalysisElement = relatedAnalyses?.find(
|
||||
(relatedAnalysis) =>
|
||||
relatedAnalysis.analysis_elements.analysis_groups.id ===
|
||||
currentGroup.id,
|
||||
)?.analysis_elements;
|
||||
}
|
||||
|
||||
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
|
||||
throw new Error(
|
||||
`Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
|
||||
const materials = toArray(group.Materjal);
|
||||
for (const material of materials) {
|
||||
const { MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner } = material;
|
||||
const containers = toArray(Konteiner);
|
||||
|
||||
for (const container of containers) {
|
||||
// Use MaterialTyyp as the key for deduplication
|
||||
const materialKey = MaterjaliTyyp;
|
||||
|
||||
if (!uniqueMaterials.has(materialKey)) {
|
||||
uniqueMaterials.set(materialKey, {
|
||||
MaterjaliTyypOID,
|
||||
MaterjaliTyyp,
|
||||
MaterjaliNimi,
|
||||
ProovinouKoodOID: container.ProovinouKoodOID,
|
||||
ProovinouKood: container.ProovinouKood,
|
||||
order: specimenOrder++,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate specimen section from unique materials
|
||||
const specimenSection = Array.from(uniqueMaterials.values()).map(material =>
|
||||
getSpecimen(
|
||||
material.MaterjaliTyypOID,
|
||||
material.MaterjaliTyyp,
|
||||
material.MaterjaliNimi,
|
||||
material.order,
|
||||
material.ProovinouKoodOID,
|
||||
material.ProovinouKood,
|
||||
)
|
||||
);
|
||||
|
||||
// Generate analysis section with correct specimen references
|
||||
const analysisSection = [];
|
||||
for (const currentGroup of analysisGroups) {
|
||||
let relatedAnalysisElement = analysisElements?.find(
|
||||
(element) => element.analysis_groups.id === currentGroup.id,
|
||||
);
|
||||
const relatedAnalyses = analyses?.filter((analysis) => {
|
||||
return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
|
||||
});
|
||||
|
||||
if (!relatedAnalysisElement) {
|
||||
relatedAnalysisElement = relatedAnalyses?.find(
|
||||
(relatedAnalysis) =>
|
||||
relatedAnalysis.analysis_elements.analysis_groups.id ===
|
||||
currentGroup.id,
|
||||
)?.analysis_elements;
|
||||
}
|
||||
|
||||
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
|
||||
throw new Error(
|
||||
`Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Find the specimen order number for this analysis group
|
||||
let specimenOrderNumber = 1;
|
||||
for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
|
||||
const materials = toArray(group.Materjal);
|
||||
for (const material of materials) {
|
||||
const materialKey = material.MaterjaliTyyp;
|
||||
const uniqueMaterial = uniqueMaterials.get(materialKey);
|
||||
if (uniqueMaterial) {
|
||||
specimenOrderNumber = uniqueMaterial.order;
|
||||
break; // Use the first material's order number
|
||||
}
|
||||
}
|
||||
if (specimenOrderNumber > 1) break; // Found a specimen, use it
|
||||
}
|
||||
|
||||
const groupXml = getAnalysisGroup(
|
||||
currentGroup.original_id,
|
||||
currentGroup.name,
|
||||
specimenOrderNumber,
|
||||
relatedAnalysisElement,
|
||||
);
|
||||
analysisSection.push(groupXml);
|
||||
}
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
|
||||
${getPais(USER, RECIPIENT, orderId)}
|
||||
<Tellimus cito="EI">
|
||||
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
||||
${getClientInstitution()}
|
||||
${getProviderInstitution()}
|
||||
${getClientPerson()}
|
||||
${getOrderEnteredPerson()}
|
||||
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
|
||||
${getPatient(person)}
|
||||
${getConfidentiality()}
|
||||
${specimenSection.join('')}
|
||||
${analysisSection?.join('')}
|
||||
</Tellimus>
|
||||
</Saadetis>`;
|
||||
}
|
||||
@@ -38,8 +38,7 @@ export async function handleAddToCart({
|
||||
countryCode: string;
|
||||
}) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const user = await requireUserInServerComponent();
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account, user } = await loadCurrentUserAccount();
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
@@ -70,8 +69,7 @@ export async function handleDeleteCartItem({ lineId }: { lineId: string }) {
|
||||
|
||||
const supabase = getSupabaseServerClient();
|
||||
const cartId = await getCartId();
|
||||
const user = await requireUserInServerComponent();
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account, user } = await loadCurrentUserAccount();
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
@@ -96,8 +94,7 @@ export async function handleNavigateToPayment({
|
||||
paymentSessionId: string;
|
||||
}) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const user = await requireUserInServerComponent();
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account, user } = await loadCurrentUserAccount();
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
@@ -137,8 +134,7 @@ export async function handleLineItemTimeout({
|
||||
lineItem: StoreCartLineItem;
|
||||
}) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const user = await requireUserInServerComponent();
|
||||
const account = await loadCurrentUserAccount();
|
||||
const { account, user } = await loadCurrentUserAccount();
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { StoreOrder } from '@medusajs/types';
|
||||
|
||||
export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>;
|
||||
|
||||
export async function createOrder({
|
||||
export async function createAnalysisOrder({
|
||||
medusaOrder,
|
||||
orderedAnalysisElements,
|
||||
}: {
|
||||
@@ -38,7 +38,7 @@ export async function createOrder({
|
||||
return orderResult.data.id;
|
||||
}
|
||||
|
||||
export async function updateOrder({
|
||||
export async function updateAnalysisOrder({
|
||||
orderId,
|
||||
orderStatus,
|
||||
}: {
|
||||
@@ -56,7 +56,7 @@ export async function updateOrder({
|
||||
.throwOnError();
|
||||
}
|
||||
|
||||
export async function updateOrderStatus({
|
||||
export async function updateAnalysisOrderStatus({
|
||||
orderId,
|
||||
medusaOrderId,
|
||||
orderStatus,
|
||||
@@ -80,12 +80,12 @@ export async function updateOrderStatus({
|
||||
.throwOnError();
|
||||
}
|
||||
|
||||
export async function getOrder({
|
||||
export async function getAnalysisOrder({
|
||||
medusaOrderId,
|
||||
orderId,
|
||||
analysisOrderId,
|
||||
}: {
|
||||
medusaOrderId?: string;
|
||||
orderId?: number;
|
||||
analysisOrderId?: number;
|
||||
}) {
|
||||
const query = getSupabaseServerAdminClient()
|
||||
.schema('medreport')
|
||||
@@ -93,15 +93,15 @@ export async function getOrder({
|
||||
.select('*')
|
||||
if (medusaOrderId) {
|
||||
query.eq('medusa_order_id', medusaOrderId);
|
||||
} else if (orderId) {
|
||||
query.eq('id', orderId);
|
||||
} else if (analysisOrderId) {
|
||||
query.eq('id', analysisOrderId);
|
||||
} else {
|
||||
throw new Error('Either medusaOrderId or orderId must be provided');
|
||||
}
|
||||
|
||||
const { data: order, error } = await query.single();
|
||||
if (error) {
|
||||
throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or orderId=${orderId}, message=${error.message}, data=${JSON.stringify(order)}`);
|
||||
throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { format } from 'date-fns';
|
||||
import Isikukood, { Gender } from 'isikukood';
|
||||
import { Tables } from '@/packages/supabase/src/database.types';
|
||||
import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants';
|
||||
import PersonalCode from '../utils';
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export const getPais = (
|
||||
sender: string,
|
||||
recipient: string,
|
||||
createdAt: Date,
|
||||
orderId: string,
|
||||
orderId: number,
|
||||
packageName = "OL",
|
||||
) => {
|
||||
if (isProd) {
|
||||
@@ -19,7 +18,7 @@ export const getPais = (
|
||||
<Pakett versioon="20">${packageName}</Pakett>
|
||||
<Saatja>${sender}</Saatja>
|
||||
<Saaja>${recipient}</Saaja>
|
||||
<Aeg>${format(createdAt, DATE_TIME_FORMAT)}</Aeg>
|
||||
<Aeg>${format(new Date(), DATE_TIME_FORMAT)}</Aeg>
|
||||
<SaadetisId>${orderId}</SaadetisId>
|
||||
<Email>info@medreport.ee</Email>
|
||||
</Pais>`;
|
||||
@@ -73,15 +72,15 @@ export const getPatient = ({
|
||||
lastName: string,
|
||||
firstName: string,
|
||||
}) => {
|
||||
const isikukood = new Isikukood(idCode);
|
||||
const { dob, gender } = PersonalCode.parsePersonalCode(idCode);
|
||||
return `<Patsient>
|
||||
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
|
||||
<Isikukood>${idCode}</Isikukood>
|
||||
<PerekonnaNimi>${lastName}</PerekonnaNimi>
|
||||
<EesNimi>${firstName}</EesNimi>
|
||||
<SynniAeg>${format(isikukood.getBirthday(), DATE_FORMAT)}</SynniAeg>
|
||||
<SynniAeg>${format(dob, DATE_FORMAT)}</SynniAeg>
|
||||
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
|
||||
<Sugu>${isikukood.getGender() === Gender.MALE ? 'M' : 'N'}</Sugu>
|
||||
<Sugu>${gender.value === 'M' ? 'M' : 'N'}</Sugu>
|
||||
</Patsient>`;
|
||||
};
|
||||
|
||||
|
||||
85
lib/utils.ts
85
lib/utils.ts
@@ -15,11 +15,12 @@ export function toArray<T>(input?: T | T[] | null): T[] {
|
||||
}
|
||||
|
||||
export function toTitleCase(str?: string) {
|
||||
if (!str) return '';
|
||||
return str.replace(
|
||||
/\w\S*/g,
|
||||
(text: string) =>
|
||||
text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||
return (
|
||||
str
|
||||
?.toLowerCase()
|
||||
.replace(/[^-'’\s]+/g, (match) =>
|
||||
match.replace(/^./, (first) => first.toUpperCase()),
|
||||
) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,8 +41,12 @@ export function sortByDate<T>(
|
||||
|
||||
export const bmiFromMetric = (kg: number, cm: number) => {
|
||||
const m = cm / 100;
|
||||
const bmi = kg / (m * m);
|
||||
return bmi ? Math.round(bmi) : NaN;
|
||||
const m2 = m * m;
|
||||
if (m2 === 0) {
|
||||
return null;
|
||||
}
|
||||
const bmi = kg / m2;
|
||||
return !Number.isNaN(bmi) ? Math.round(bmi) : null;
|
||||
};
|
||||
|
||||
export function getBmiStatus(
|
||||
@@ -58,7 +63,9 @@ export function getBmiStatus(
|
||||
) || null;
|
||||
const bmi = bmiFromMetric(params.weight, params.height);
|
||||
|
||||
if (!thresholdByAge || Number.isNaN(bmi)) return null;
|
||||
if (!thresholdByAge || bmi === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bmi > thresholdByAge.obesity_min) return BmiCategory.OBESE;
|
||||
if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT;
|
||||
@@ -83,9 +90,61 @@ export function getBmiBackgroundColor(bmiStatus: BmiCategory | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getGenderStringFromPersonalCode(personalCode: string) {
|
||||
const person = new Isikukood(personalCode);
|
||||
if (person.getGender() === Gender.FEMALE) return 'common:female';
|
||||
if (person.getGender() === Gender.MALE) return 'common:male';
|
||||
return 'common:unknown';
|
||||
type AgeRange = '18-29' | '30-39' | '40-49' | '50-59' | '60';
|
||||
export default class PersonalCode {
|
||||
static getPersonalCode(personalCode: string | null) {
|
||||
if (!personalCode) {
|
||||
return null;
|
||||
}
|
||||
if (personalCode.toLowerCase().startsWith('ee')) {
|
||||
return personalCode.substring(2);
|
||||
}
|
||||
return personalCode;
|
||||
}
|
||||
|
||||
static parsePersonalCode(personalCode: string): {
|
||||
ageRange: AgeRange;
|
||||
gender: { label: string; value: string };
|
||||
dob: Date;
|
||||
age: number;
|
||||
} {
|
||||
const parsed = new Isikukood(personalCode);
|
||||
const ageRange = (() => {
|
||||
const age = parsed.getAge();
|
||||
if (age >= 18 && age <= 29) {
|
||||
return '18-29';
|
||||
}
|
||||
if (age >= 30 && age <= 39) {
|
||||
return '30-39';
|
||||
}
|
||||
if (age >= 40 && age <= 49) {
|
||||
return '40-49';
|
||||
}
|
||||
if (age >= 50 && age <= 59) {
|
||||
return '50-59';
|
||||
}
|
||||
if (age >= 60) {
|
||||
return '60';
|
||||
}
|
||||
throw new Error('Age range not supported, age=' + age);
|
||||
})();
|
||||
const gender = (() => {
|
||||
const gender = parsed.getGender();
|
||||
switch (gender) {
|
||||
case Gender.FEMALE:
|
||||
return { label: 'common:female', value: 'F' };
|
||||
case Gender.MALE:
|
||||
return { label: 'common:male', value: 'M' };
|
||||
default:
|
||||
throw new Error('Gender not supported');
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
ageRange,
|
||||
gender,
|
||||
dob: parsed.getBirthday(),
|
||||
age: parsed.getAge(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ const getUser = (request: NextRequest, response: NextResponse) => {
|
||||
export async function middleware(request: NextRequest) {
|
||||
const secureHeaders = await createResponseWithSecureHeaders();
|
||||
const response = NextResponse.next(secureHeaders);
|
||||
const url = new URL(request.url);
|
||||
const lang = url.searchParams.get('lang');
|
||||
|
||||
// set a unique request ID for each request
|
||||
// this helps us log and trace requests
|
||||
@@ -35,6 +37,10 @@ export async function middleware(request: NextRequest) {
|
||||
// apply CSRF protection for mutating requests
|
||||
const csrfResponse = await withCsrfMiddleware(request, response);
|
||||
|
||||
if (lang) {
|
||||
csrfResponse.cookies.set('lang', lang);
|
||||
}
|
||||
|
||||
// handle patterns for specific routes
|
||||
const handlePattern = matchUrlPattern(request.url);
|
||||
|
||||
@@ -176,6 +182,14 @@ function getPatterns() {
|
||||
return NextResponse.redirect(
|
||||
new URL(pathsConfig.app.home, req.nextUrl.origin).href,
|
||||
);
|
||||
} else if (
|
||||
!['test', 'localhost'].some((pathString) =>
|
||||
process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString),
|
||||
)
|
||||
) {
|
||||
return NextResponse.redirect(
|
||||
new URL('https://medreport.ee', req.nextUrl.origin).href,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"isikukood": "3.1.7",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"libphonenumber-js": "^1.12.15",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
@@ -101,7 +102,7 @@
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"cssnano": "^7.0.7",
|
||||
"dotenv": "^16.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"pino-pretty": "13.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"supabase": "^2.30.4",
|
||||
"tailwindcss": "4.1.7",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user