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"
|
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
||||||
|
|
||||||
# AUTH
|
# AUTH
|
||||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
NEXT_PUBLIC_AUTH_PASSWORD=false
|
||||||
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
||||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
@@ -65,3 +65,9 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
|
|||||||
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
||||||
|
|
||||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
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
|
# SITE
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||||
|
|
||||||
# SUPABASE DEVELOPMENT
|
# SUPABASE DEVELOPMENT
|
||||||
|
|
||||||
@@ -25,6 +26,22 @@ EMAIL_PORT=1025 # or 465 for SSL
|
|||||||
EMAIL_TLS=false
|
EMAIL_TLS=false
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
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
|
||||||
MEDUSA_BACKEND_URL=http://localhost:9000
|
MEDUSA_BACKEND_URL=http://localhost:9000
|
||||||
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
|
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ import { Button } from '@kit/ui/button';
|
|||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { featureFlagsConfig } from '@kit/shared/config';
|
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
||||||
|
import { useAuthConfig } from '@kit/shared/hooks';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
|
|
||||||
|
|
||||||
const ModeToggle = dynamic(() =>
|
const ModeToggle = dynamic(() =>
|
||||||
import('@kit/ui/mode-toggle').then((mod) => ({
|
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||||
@@ -60,6 +58,8 @@ export function SiteHeaderAccountSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AuthButtons() {
|
function AuthButtons() {
|
||||||
|
const { config } = useAuthConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
|
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
|
||||||
<div className={'hidden md:flex'}>
|
<div className={'hidden md:flex'}>
|
||||||
@@ -68,19 +68,25 @@ function AuthButtons() {
|
|||||||
</If>
|
</If>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'flex gap-x-2.5'}>
|
{config && (
|
||||||
<Button className={'block'} asChild variant={'ghost'}>
|
<div className={'flex gap-x-2.5'}>
|
||||||
<Link href={pathsConfig.auth.signIn}>
|
{(config.providers.password || config.providers.oAuth.length > 0) && (
|
||||||
<Trans i18nKey={'auth:signIn'} />
|
<Button className={'block'} asChild variant={'ghost'}>
|
||||||
</Link>
|
<Link href={pathsConfig.auth.signIn}>
|
||||||
</Button>
|
<Trans i18nKey={'auth:signIn'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
{config.providers.password && (
|
||||||
<Link href={pathsConfig.auth.signUp}>
|
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||||
<Trans i18nKey={'auth:signUp'} />
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
</Link>
|
<Trans i18nKey={'auth:signUp'} />
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
import { ArrowRightIcon } from 'lucide-react';
|
import { ArrowRightIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { CtaButton, Hero } from '@kit/ui/marketing';
|
import { CtaButton, Hero } from '@kit/ui/marketing';
|
||||||
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
|
|||||||
return (
|
return (
|
||||||
<div className={'flex space-x-4'}>
|
<div className={'flex space-x-4'}>
|
||||||
<CtaButton>
|
<CtaButton>
|
||||||
<Link href={'/auth/sign-up'}>
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
<span className={'flex items-center space-x-0.5'}>
|
<span className={'flex items-center space-x-0.5'}>
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey={'common:getStarted'} />
|
<Trans i18nKey={'common:getStarted'} />
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ type ProcessedMessage = {
|
|||||||
hasPartialAnalysisResponse: boolean;
|
hasPartialAnalysisResponse: boolean;
|
||||||
hasFullAnalysisResponse: boolean;
|
hasFullAnalysisResponse: boolean;
|
||||||
medusaOrderId: string | undefined;
|
medusaOrderId: string | undefined;
|
||||||
|
analysisOrderId: number | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GroupedResults = {
|
type GroupedResults = {
|
||||||
processed: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
|
processed: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
|
||||||
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
|
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function syncAnalysisResults() {
|
export default async function syncAnalysisResults() {
|
||||||
@@ -37,14 +38,14 @@ export default async function syncAnalysisResults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupedResults = processedMessages.reduce((acc, result) => {
|
const groupedResults = processedMessages.reduce((acc, result) => {
|
||||||
if (result.medusaOrderId) {
|
if (result.analysisOrderId) {
|
||||||
if (result.hasAnalysisResponse) {
|
if (result.hasAnalysisResponse) {
|
||||||
if (!acc.processed) {
|
if (!acc.processed) {
|
||||||
acc.processed = [];
|
acc.processed = [];
|
||||||
}
|
}
|
||||||
acc.processed.push({
|
acc.processed.push({
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
medusaOrderId: result.medusaOrderId,
|
analysisOrderId: result.analysisOrderId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!acc.waitingForResults) {
|
if (!acc.waitingForResults) {
|
||||||
@@ -52,7 +53,7 @@ export default async function syncAnalysisResults() {
|
|||||||
}
|
}
|
||||||
acc.waitingForResults.push({
|
acc.waitingForResults.push({
|
||||||
messageId: result.messageId,
|
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.',
|
'Successfully sent out open job notification emails to doctors.',
|
||||||
);
|
);
|
||||||
await createNotificationLog({
|
await createNotificationLog({
|
||||||
action: NotificationAction.NEW_JOBS_ALERT,
|
action: NotificationAction.DOCTOR_NEW_JOBS,
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -39,7 +39,7 @@ export const POST = async (request: NextRequest) => {
|
|||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
await createNotificationLog({
|
await createNotificationLog({
|
||||||
action: NotificationAction.NEW_JOBS_ALERT,
|
action: NotificationAction.DOCTOR_NEW_JOBS,
|
||||||
status: 'FAIL',
|
status: 'FAIL',
|
||||||
comment: e?.message,
|
comment: e?.message,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||||
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||||
orderId: medusaOrderId,
|
orderId: medreportOrder.id,
|
||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
||||||
import { retrieveOrder } from "@lib/data";
|
import { retrieveOrder } from "@lib/data";
|
||||||
import { getAccountAdmin } from "~/lib/services/account.service";
|
import { getAccountAdmin } from "~/lib/services/account.service";
|
||||||
@@ -14,9 +14,9 @@ export async function POST(request: Request) {
|
|||||||
const { medusaOrderId } = await request.json();
|
const { medusaOrderId } = await request.json();
|
||||||
|
|
||||||
const medusaOrder = await retrieveOrder(medusaOrderId)
|
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 });
|
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
|
||||||
|
|
||||||
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
|
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[],
|
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||||
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||||
orderId: medusaOrderId,
|
orderId: analysisOrder.id,
|
||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
orderCreatedAt: new Date(analysisOrder.created_at),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,19 +1,62 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import type { NextRequest } from 'next/server';
|
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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
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) {
|
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 service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
|
const oauthResult = await service.exchangeCodeForSession(authCode);
|
||||||
|
if (!("isSuccess" in oauthResult)) {
|
||||||
|
return redirectOnError(oauthResult.searchParams);
|
||||||
|
}
|
||||||
|
|
||||||
const { nextPath } = await service.exchangeCodeForSession(request, {
|
const api = createAccountsApi(getSupabaseServerClient());
|
||||||
joinTeamPath: pathsConfig.app.joinTeam,
|
|
||||||
redirectPath: pathsConfig.app.home,
|
|
||||||
});
|
|
||||||
|
|
||||||
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';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
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 { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
|
|
||||||
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 { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
|
||||||
|
import PasswordOption from './components/PasswordOption';
|
||||||
|
|
||||||
interface SignInPageProps {
|
interface SignInPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -26,47 +21,26 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
const { invite_token: inviteToken, next = pathsConfig.app.home } =
|
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
|
||||||
await searchParams;
|
await searchParams;
|
||||||
|
|
||||||
const signUpPath =
|
const authConfig = await getServerAuthConfig();
|
||||||
pathsConfig.auth.signUp +
|
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
|
||||||
|
|
||||||
const paths = {
|
if (authConfig.providers.password) {
|
||||||
callback: pathsConfig.auth.callback,
|
return (
|
||||||
returnPath: next ?? pathsConfig.app.home,
|
<PasswordOption
|
||||||
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}
|
inviteToken={inviteToken}
|
||||||
paths={paths}
|
returnPath={returnPath}
|
||||||
providers={authConfig.providers}
|
providers={authConfig.providers}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className={'flex justify-center'}>
|
if (authConfig.providers.oAuth.includes('keycloak')) {
|
||||||
<Button asChild variant={'link'} size={'sm'}>
|
return <SignInPageClientRedirect />;
|
||||||
<Link href={signUpPath} prefetch={true}>
|
}
|
||||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
|
||||||
</Link>
|
return null;
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(SignInPage);
|
export default withI18n(SignInPage);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
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 { Button } from '@kit/ui/button';
|
||||||
import { Heading } from '@kit/ui/heading';
|
import { Heading } from '@kit/ui/heading';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
@@ -37,6 +38,12 @@ async function SignUpPage({ searchParams }: Props) {
|
|||||||
pathsConfig.auth.signIn +
|
pathsConfig.auth.signIn +
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
|
const authConfig = await getServerAuthConfig();
|
||||||
|
|
||||||
|
if (!authConfig.providers.password) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex flex-col items-center gap-1'}>
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
@@ -50,8 +57,7 @@ async function SignUpPage({ searchParams }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SignUpMethodsContainer
|
<SignUpMethodsContainer
|
||||||
providers={authConfig.providers}
|
authConfig={authConfig}
|
||||||
displayTermsCheckbox={authConfig.displayTermsCheckbox}
|
|
||||||
inviteToken={inviteToken}
|
inviteToken={inviteToken}
|
||||||
paths={paths}
|
paths={paths}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { User } from '@supabase/supabase-js';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ExternalLink } from '@/public/assets/external-link';
|
import { ExternalLink } from '@/public/assets/external-link';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -21,40 +22,87 @@ import {
|
|||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { Trans } from '@kit/ui/trans';
|
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 { 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({
|
const form = useForm({
|
||||||
resolver: zodResolver(UpdateAccountSchema),
|
resolver: zodResolver(UpdateAccountSchemaClient({ isEmailUser })),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues,
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
personalCode: '',
|
|
||||||
email: user.email,
|
|
||||||
phone: '',
|
|
||||||
city: '',
|
|
||||||
weight: 0,
|
|
||||||
height: 0,
|
|
||||||
userConsent: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
||||||
onSubmit={form.handleSubmit(onUpdateAccount)}
|
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
name="firstName"
|
name="firstName"
|
||||||
|
disabled={hasFirstName && !isEmailUser}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans i18nKey={'common:formField:firstName'} />
|
<Trans i18nKey={'common:formField:firstName'} />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} autoFocus={!hasFirstName} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -63,13 +111,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="lastName"
|
name="lastName"
|
||||||
|
disabled={hasLastName && !isEmailUser}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans i18nKey={'common:formField:lastName'} />
|
<Trans i18nKey={'common:formField:lastName'} />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} autoFocus={hasFirstName && !hasLastName} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -78,6 +127,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="personalCode"
|
name="personalCode"
|
||||||
|
disabled={hasPersonalCode && !isEmailUser}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -93,13 +143,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="email"
|
name="email"
|
||||||
|
disabled={hasEmail}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans i18nKey={'common:formField:email'} />
|
<Trans i18nKey={'common:formField:email'} />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} disabled />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -121,72 +172,76 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
{!isEmailUser && (
|
||||||
name="city"
|
<>
|
||||||
render={({ field }) => (
|
<FormField
|
||||||
<FormItem>
|
name="city"
|
||||||
<FormLabel>
|
render={({ field }) => (
|
||||||
<Trans i18nKey={'common:formField:city'} />
|
<FormItem>
|
||||||
</FormLabel>
|
<FormLabel>
|
||||||
<FormControl>
|
<Trans i18nKey={'common:formField:city'} />
|
||||||
<Input {...field} />
|
</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input {...field} />
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row justify-between gap-4">
|
<div className="flex flex-row justify-between gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
name="weight"
|
name="weight"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans i18nKey={'common:formField:weight'} />
|
<Trans i18nKey={'common:formField:weight'} />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="kg"
|
placeholder="kg"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(
|
||||||
e.target.value === '' ? null : Number(e.target.value),
|
e.target.value === '' ? null : Number(e.target.value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="height"
|
name="height"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans i18nKey={'common:formField:height'} />
|
<Trans i18nKey={'common:formField:height'} />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="cm"
|
placeholder="cm"
|
||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(
|
||||||
e.target.value === '' ? null : Number(e.target.value),
|
e.target.value === '' ? null : Number(e.target.value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="userConsent"
|
name="userConsent"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import Isikukood from 'isikukood';
|
||||||
|
import parsePhoneNumber from 'libphonenumber-js/min';
|
||||||
|
|
||||||
export const UpdateAccountSchema = z.object({
|
const updateAccountSchema = {
|
||||||
firstName: z
|
firstName: z
|
||||||
.string({
|
.string({
|
||||||
error: 'First name is required',
|
error: 'First name is required',
|
||||||
@@ -10,20 +12,42 @@ export const UpdateAccountSchema = z.object({
|
|||||||
.string({
|
.string({
|
||||||
error: 'Last name is required',
|
error: 'Last name is required',
|
||||||
})
|
})
|
||||||
.nonempty(),
|
.nonempty({
|
||||||
personalCode: z
|
error: 'common:formFieldError.stringNonEmpty',
|
||||||
.string({
|
}),
|
||||||
error: 'Personal code is required',
|
personalCode: z.string().refine(
|
||||||
})
|
(val) => {
|
||||||
.nonempty(),
|
try {
|
||||||
|
return new Isikukood(val).validate();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'common:formFieldError.invalidPersonalCode',
|
||||||
|
},
|
||||||
|
),
|
||||||
email: z.string().email({
|
email: z.string().email({
|
||||||
message: 'Email is required',
|
message: 'Email is required',
|
||||||
}),
|
}),
|
||||||
phone: z
|
phone: z
|
||||||
.string({
|
.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(),
|
city: z.string().optional(),
|
||||||
weight: z
|
weight: z
|
||||||
.number({
|
.number({
|
||||||
@@ -45,4 +69,34 @@ export const UpdateAccountSchema = z.object({
|
|||||||
userConsent: z.boolean().refine((val) => val === true, {
|
userConsent: z.boolean().refine((val) => val === true, {
|
||||||
message: 'Must be 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';
|
'use server';
|
||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { updateCustomer } from '@lib/data/customer';
|
import { updateCustomer } from '@lib/data/customer';
|
||||||
|
|
||||||
import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
|
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 { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
|
import { UpdateAccountSchemaServer } from '../schemas/update-account.schema';
|
||||||
import { UpdateAccountSchema } from '../schemas/update-account.schema';
|
|
||||||
|
|
||||||
export const onUpdateAccount = enhanceAction(
|
export const onUpdateAccount = enhanceAction(
|
||||||
async (params: AccountSubmitData) => {
|
async (params: AccountSubmitData) => {
|
||||||
@@ -28,22 +25,23 @@ export const onUpdateAccount = enhanceAction(
|
|||||||
console.warn('On update account error: ', err);
|
console.warn('On update account error: ', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateCustomer({
|
try {
|
||||||
first_name: params.firstName,
|
await updateCustomer({
|
||||||
last_name: params.lastName,
|
first_name: params.firstName,
|
||||||
phone: params.phone,
|
last_name: params.lastName,
|
||||||
});
|
phone: params.phone,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update Medusa customer", e);
|
||||||
|
}
|
||||||
|
|
||||||
const hasUnseenMembershipConfirmation =
|
const hasUnseenMembershipConfirmation =
|
||||||
await api.hasUnseenMembershipConfirmation();
|
await api.hasUnseenMembershipConfirmation();
|
||||||
|
return {
|
||||||
if (hasUnseenMembershipConfirmation) {
|
hasUnseenMembershipConfirmation,
|
||||||
redirect(pathsConfig.auth.membershipConfirmation);
|
|
||||||
} else {
|
|
||||||
redirect(pathsConfig.app.selectPackage);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
schema: UpdateAccountSchema,
|
schema: UpdateAccountSchemaServer,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { signOutAction } from '@/lib/actions/sign-out';
|
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 { BackButton } from '@kit/shared/components/back-button';
|
||||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
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 { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
import { UpdateAccountForm } from './_components/update-account-form';
|
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() {
|
async function UpdateAccount() {
|
||||||
const client = getSupabaseServerClient();
|
const { account, user } = await loadCurrentUserAccount();
|
||||||
|
|
||||||
const {
|
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
|
||||||
data: { user },
|
const isEmailUser = user?.app_metadata?.provider === 'email';
|
||||||
} = await client.auth.getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(pathsConfig.auth.signIn);
|
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 (
|
return (
|
||||||
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
<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">
|
<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">
|
<p className="text-muted-foreground pt-1 text-sm">
|
||||||
<Trans i18nKey={'account:updateAccount:description'} />
|
<Trans i18nKey={'account:updateAccount:description'} />
|
||||||
</p>
|
</p>
|
||||||
<UpdateAccountForm user={user} />
|
<UpdateAccountForm defaultValues={defaultValues} isEmailUser={isEmailUser} />
|
||||||
</div>
|
</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 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>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
|
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
@@ -22,14 +23,15 @@ export default async function AnalysisResultsPage({
|
|||||||
id: string;
|
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) {
|
||||||
|
return redirect("/");
|
||||||
if (!account?.id || !analysisResponse) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createPageViewLog({
|
await createPageViewLog({
|
||||||
@@ -37,6 +39,19 @@ export default async function AnalysisResultsPage({
|
|||||||
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader />
|
<PageHeader />
|
||||||
|
|||||||
@@ -28,9 +28,13 @@ function BookingPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBreadcrumbs />
|
<AppBreadcrumbs />
|
||||||
<h3 className="mt-8">
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'booking:title'} />}
|
||||||
|
description={<Trans i18nKey={'booking:description'} />}
|
||||||
|
/>
|
||||||
|
<h4 className="mt-8">
|
||||||
<Trans i18nKey="booking:noCategories" />
|
<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 { listProductTypes } from "@lib/data/products";
|
||||||
import { placeOrder, retrieveCart } from "@lib/data/cart";
|
import { placeOrder, retrieveCart } from "@lib/data/cart";
|
||||||
import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
|
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 { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
|
||||||
import { createNotificationsApi } from '@kit/notifications/api';
|
import { createNotificationsApi } from '@kit/notifications/api';
|
||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
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_PACKAGES_TYPE_HANDLE = 'analysis-packages';
|
||||||
|
const ANALYSIS_TYPE_HANDLE = 'synlab-analysis';
|
||||||
const MONTONIO_PAID_STATUS = 'PAID';
|
const MONTONIO_PAID_STATUS = 'PAID';
|
||||||
|
|
||||||
const env = () => z
|
const env = () => z
|
||||||
@@ -28,24 +30,27 @@ const env = () => z
|
|||||||
error: 'NEXT_PUBLIC_SITE_URL is required',
|
error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
|
isEnabledDispatchOnMontonioCallback: z
|
||||||
|
.boolean({
|
||||||
|
error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.parse({
|
.parse({
|
||||||
emailSender: process.env.EMAIL_SENDER,
|
emailSender: process.env.EMAIL_SENDER,
|
||||||
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
||||||
|
isEnabledDispatchOnMontonioCallback: process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
const sendEmail = async ({
|
const sendEmail = async ({
|
||||||
account,
|
account,
|
||||||
email,
|
email,
|
||||||
analysisPackageName,
|
analysisPackageName,
|
||||||
personName,
|
|
||||||
partnerLocationName,
|
partnerLocationName,
|
||||||
language,
|
language,
|
||||||
}: {
|
}: {
|
||||||
account: AccountWithParams,
|
account: Pick<AccountWithParams, 'name' | 'id'>,
|
||||||
email: string,
|
email: string,
|
||||||
analysisPackageName: string,
|
analysisPackageName: string,
|
||||||
personName: string,
|
|
||||||
partnerLocationName: string,
|
partnerLocationName: string,
|
||||||
language: string,
|
language: string,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -58,7 +63,7 @@ const sendEmail = async ({
|
|||||||
|
|
||||||
const { html, subject } = await renderSynlabAnalysisPackageEmail({
|
const { html, subject } = await renderSynlabAnalysisPackageEmail({
|
||||||
analysisPackageName,
|
analysisPackageName,
|
||||||
personName,
|
personName: account.name,
|
||||||
partnerLocationName,
|
partnerLocationName,
|
||||||
language,
|
language,
|
||||||
});
|
});
|
||||||
@@ -83,9 +88,7 @@ const sendEmail = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processMontonioCallback(orderToken: string) {
|
async function decodeOrderToken(orderToken: string) {
|
||||||
const { language } = await createI18nServerInstance();
|
|
||||||
|
|
||||||
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
|
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
|
||||||
|
|
||||||
const decoded = jwt.verify(orderToken, secretKey, {
|
const decoded = jwt.verify(orderToken, secretKey, {
|
||||||
@@ -96,54 +99,120 @@ export async function processMontonioCallback(orderToken: string) {
|
|||||||
throw new Error("Payment not successful");
|
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) {
|
if (!account) {
|
||||||
throw new Error("Account not found in context");
|
throw new Error("Account not found in context");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
|
const decoded = await decodeOrderToken(orderToken);
|
||||||
if (!cartId) {
|
const cart = await getCartByOrderToken(decoded);
|
||||||
throw new Error("Cart ID not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cart = await retrieveCart(cartId);
|
const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false });
|
||||||
if (!cart) {
|
|
||||||
throw new Error("Cart not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
|
|
||||||
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
||||||
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
|
|
||||||
|
|
||||||
const { productTypes } = await listProductTypes();
|
try {
|
||||||
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
|
const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id });
|
||||||
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
|
console.info(`Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`);
|
||||||
|
return { success: true, orderId: existingAnalysisOrder.id };
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
const orderResult = {
|
const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements });
|
||||||
medusaOrderId: medusaOrder.id,
|
const orderResult = await getOrderResultParameters(medusaOrder);
|
||||||
email: medusaOrder.email,
|
|
||||||
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
|
|
||||||
analysisPackageName: analysisPackageOrderItem?.title ?? '',
|
|
||||||
orderedAnalysisElements,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult;
|
const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult;
|
||||||
const personName = account.name;
|
|
||||||
|
|
||||||
if (email && analysisPackageName) {
|
if (email) {
|
||||||
try {
|
if (analysisPackageOrder) {
|
||||||
await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language });
|
await sendAnalysisPackageOrderEmail({ account, email, analysisPackageOrder });
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error("Failed to send email", error);
|
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 {
|
} else {
|
||||||
// @TODO send email for separate analyses
|
console.error("Missing email to send order result email", orderResult);
|
||||||
console.error("Missing email or analysisPackageName", orderResult);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
|
if (env().isEnabledDispatchOnMontonioCallback) {
|
||||||
|
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, orderId };
|
return { success: true, orderId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Cart from '../../_components/cart';
|
|||||||
import { listProductTypes } from '@lib/data/products';
|
import { listProductTypes } from '@lib/data/products';
|
||||||
import CartTimer from '../../_components/cart/cart-timer';
|
import CartTimer from '../../_components/cart/cart-timer';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await createI18nServerInstance();
|
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) => {
|
const cart = await retrieveCart().catch((error) => {
|
||||||
console.error(error);
|
console.error("Failed to retrieve cart", error);
|
||||||
return notFound();
|
return notFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,3 +51,5 @@ export default async function CartPage() {
|
|||||||
</PageBody>
|
</PageBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withI18n(CartPage);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function OrderAnalysisPage() {
|
async function OrderAnalysisPage() {
|
||||||
const account = await loadCurrentUserAccount();
|
const { account } = await loadCurrentUserAccount();
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found');
|
throw new Error('Account not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ async function OrderHealthAnalysisPage() {
|
|||||||
description={<Trans i18nKey={'order-health-analysis:description'} />}
|
description={<Trans i18nKey={'order-health-analysis:description'} />}
|
||||||
/>
|
/>
|
||||||
<PageBody>
|
<PageBody>
|
||||||
|
<h4 className="mt-8">
|
||||||
|
<Trans i18nKey="booking:noCategories" />
|
||||||
|
</h4>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page';
|
|||||||
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
import { getOrder } from '~/lib/services/order.service';
|
import { getAnalysisOrder } from '~/lib/services/order.service';
|
||||||
import { retrieveOrder } from '@lib/data/orders';
|
import { retrieveOrder } from '@lib/data/orders';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
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) {
|
if (!order) {
|
||||||
redirect(pathsConfig.app.myOrders);
|
redirect(pathsConfig.app.myOrders);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page';
|
|||||||
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
import { getOrder } from '~/lib/services/order.service';
|
import { getAnalysisOrder } from '~/lib/services/order.service';
|
||||||
import { retrieveOrder } from '@lib/data/orders';
|
import { retrieveOrder } from '@lib/data/orders';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
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) {
|
if (!order) {
|
||||||
redirect(pathsConfig.app.myOrders);
|
redirect(pathsConfig.app.myOrders);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ async function OrdersPage() {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{analysisOrders.length === 0 && (
|
||||||
|
<h5 className="mt-6">
|
||||||
|
<Trans i18nKey="orders:noOrders" />
|
||||||
|
</h5>
|
||||||
|
)}
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export const generateMetadata = async () => {
|
|||||||
async function UserHomePage() {
|
async function UserHomePage() {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const account = await loadCurrentUserAccount();
|
const { account } = await loadCurrentUserAccount();
|
||||||
const api = await createAccountsApi(client);
|
const api = createAccountsApi(client);
|
||||||
const bmiThresholds = await api.fetchBmiThresholds();
|
const bmiThresholds = await api.fetchBmiThresholds();
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|||||||
@@ -55,11 +55,15 @@ export default function AnalysisLocation({ cart, synlabAnalyses }: { cart: Store
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit((data) => onSubmit(data))}
|
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
|
<Select
|
||||||
value={form.watch('locationId')}
|
value={form.watch('locationId')}
|
||||||
@@ -106,11 +110,6 @@ export default function AnalysisLocation({ cart, synlabAnalyses }: { cart: Store
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<Trans i18nKey={'cart:locations.description'} />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function CartItem({ item, currencyCode }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="w-full" data-testid="product-row">
|
<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
|
<p
|
||||||
className="txt-medium-plus text-ui-fg-base"
|
className="txt-medium-plus text-ui-fg-base"
|
||||||
data-testid="product-title"
|
data-testid="product-title"
|
||||||
@@ -26,11 +26,11 @@ export default function CartItem({ item, currencyCode }: {
|
|||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="px-6">
|
<TableCell className="px-4 sm:px-6">
|
||||||
{item.quantity}
|
{item.quantity}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="min-w-[80px] px-6">
|
<TableCell className="min-w-[80px] px-4 sm:px-6">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
value: item.unit_price,
|
value: item.unit_price,
|
||||||
currencyCode,
|
currencyCode,
|
||||||
@@ -38,7 +38,7 @@ export default function CartItem({ item, currencyCode }: {
|
|||||||
})}
|
})}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="min-w-[80px] px-6">
|
<TableCell className="min-w-[80px] px-4 sm:px-6 text-right">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
value: item.total,
|
value: item.total,
|
||||||
currencyCode,
|
currencyCode,
|
||||||
@@ -46,7 +46,7 @@ export default function CartItem({ item, currencyCode }: {
|
|||||||
})}
|
})}
|
||||||
</TableCell>
|
</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]">
|
<span className="flex gap-x-1 justify-end w-[60px]">
|
||||||
<CartItemDelete id={item.id} />
|
<CartItemDelete id={item.id} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -22,19 +22,19 @@ export default function CartItems({ cart, items, productColumnLabelKey }: {
|
|||||||
<Table className="rounded-lg border border-separate">
|
<Table className="rounded-lg border border-separate">
|
||||||
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="px-6">
|
<TableHead className="px-4 sm:px-6">
|
||||||
<Trans i18nKey={productColumnLabelKey} />
|
<Trans i18nKey={productColumnLabelKey} />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-6">
|
<TableHead className="px-4 sm:px-6">
|
||||||
<Trans i18nKey="cart:table.quantity" />
|
<Trans i18nKey="cart:table.quantity" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-6 min-w-[100px]">
|
<TableHead className="px-4 sm:px-6 min-w-[100px]">
|
||||||
<Trans i18nKey="cart:table.price" />
|
<Trans i18nKey="cart:table.price" />
|
||||||
</TableHead>
|
</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" />
|
<Trans i18nKey="cart:table.total" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-6">
|
<TableHead className="px-4 sm:px-6">
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</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 { Badge, Text } from "@medusajs/ui"
|
||||||
import { toast } from '@kit/ui/sonner';
|
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 { convertToLocale } from "@lib/util/money"
|
||||||
import { StoreCart, StorePromotion } from "@medusajs/types"
|
import { StoreCart, StorePromotion } from "@medusajs/types"
|
||||||
import Trash from "@modules/common/icons/trash"
|
import Trash from "@modules/common/icons/trash"
|
||||||
@@ -16,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { addPromotionCodeAction, removePromotionCodeAction } from "./discount-code-actions";
|
||||||
|
|
||||||
const DiscountCodeSchema = z.object({
|
const DiscountCodeSchema = z.object({
|
||||||
code: z.string().min(1),
|
code: z.string().min(1),
|
||||||
@@ -31,42 +31,35 @@ export default function DiscountCode({ cart }: {
|
|||||||
const { promotions = [] } = cart;
|
const { promotions = [] } = cart;
|
||||||
|
|
||||||
const removePromotionCode = async (code: string) => {
|
const removePromotionCode = async (code: string) => {
|
||||||
const validPromotions = promotions.filter(
|
const appliedCodes = promotions
|
||||||
(promotion) => promotion.code !== code,
|
.filter((p) => p.code !== undefined)
|
||||||
)
|
.map((p) => p.code!)
|
||||||
|
|
||||||
await applyPromotions(
|
const loading = toast.loading(t('cart:discountCode.removeLoading'));
|
||||||
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!),
|
|
||||||
{
|
const result = await removePromotionCodeAction(code, appliedCodes)
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(t('cart:discountCode.removeSuccess'));
|
toast.dismiss(loading);
|
||||||
},
|
if (result.success) {
|
||||||
onError: () => {
|
toast.success(t('cart:discountCode.removeSuccess'));
|
||||||
toast.error(t('cart:discountCode.removeError'));
|
} else {
|
||||||
},
|
toast.error(t('cart:discountCode.removeError'));
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPromotionCode = async (code: string) => {
|
const addPromotionCode = async (code: string) => {
|
||||||
const codes = promotions
|
const loading = toast.loading(t('cart:discountCode.addLoading'));
|
||||||
.filter((p) => p.code === undefined)
|
const result = await addPromotionCodeAction(code)
|
||||||
.map((p) => p.code!)
|
|
||||||
codes.push(code.toString())
|
|
||||||
|
|
||||||
await applyPromotions(codes, {
|
toast.dismiss(loading);
|
||||||
onSuccess: () => {
|
if (result.success) {
|
||||||
toast.success(t('cart:discountCode.addSuccess'));
|
toast.success(t('cart:discountCode.addSuccess'));
|
||||||
},
|
form.reset()
|
||||||
onError: () => {
|
} else {
|
||||||
toast.error(t('cart:discountCode.addError'));
|
toast.error(t('cart:discountCode.addError'));
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
|
||||||
form.reset()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [message, formAction] = useActionState(submitPromotionForm, null)
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof DiscountCodeSchema>>({
|
const form = useForm<z.infer<typeof DiscountCodeSchema>>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -76,11 +69,15 @@ export default function DiscountCode({ cart }: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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 {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
|
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
|
<FormField
|
||||||
name={'code'}
|
name={'code'}
|
||||||
@@ -96,14 +93,14 @@ export default function DiscountCode({ cart }: {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-full"
|
className="h-min"
|
||||||
>
|
>
|
||||||
<Trans i18nKey={'cart:discountCode.apply'} />
|
<Trans i18nKey={'cart:discountCode.apply'} />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{promotions.length > 0 ? (
|
{promotions.length > 0 && (
|
||||||
<div className="w-full flex items-center mt-4">
|
<div className="w-full flex items-center mt-4">
|
||||||
<div className="flex flex-col w-full gap-y-2">
|
<div className="flex flex-col w-full gap-y-2">
|
||||||
<p>
|
<p>
|
||||||
@@ -117,12 +114,12 @@ export default function DiscountCode({ cart }: {
|
|||||||
className="flex items-center justify-between w-full max-w-full mb-2"
|
className="flex items-center justify-between w-full max-w-full mb-2"
|
||||||
data-testid="discount-row"
|
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">
|
<span className="truncate" data-testid="discount-code">
|
||||||
<Badge
|
<Badge
|
||||||
color={promotion.is_automatic ? "green" : "grey"}
|
color={promotion.is_automatic ? "green" : "grey"}
|
||||||
size="small"
|
size="small"
|
||||||
className="px-4"
|
className="px-4 text-sm"
|
||||||
>
|
>
|
||||||
{promotion.code}
|
{promotion.code}
|
||||||
</Badge>{" "}
|
</Badge>{" "}
|
||||||
@@ -135,7 +132,7 @@ export default function DiscountCode({ cart }: {
|
|||||||
"percentage"
|
"percentage"
|
||||||
? `${promotion.application_method.value}%`
|
? `${promotion.application_method.value}%`
|
||||||
: convertToLocale({
|
: convertToLocale({
|
||||||
amount: promotion.application_method.value,
|
amount: Number(promotion.application_method.value),
|
||||||
currency_code:
|
currency_code:
|
||||||
promotion.application_method
|
promotion.application_method
|
||||||
.currency_code,
|
.currency_code,
|
||||||
@@ -173,10 +170,6 @@ export default function DiscountCode({ cart }: {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<Trans i18nKey={'cart:discountCode.subtitle'} />
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,14 +78,14 @@ export default function Cart({
|
|||||||
</div>
|
</div>
|
||||||
{hasCartItems && (
|
{hasCartItems && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end gap-x-4 px-6 pt-4">
|
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 pt-2 sm:pt-4">
|
||||||
<div className="mr-[36px]">
|
<div className="w-full sm:w-auto sm:mr-[42px]">
|
||||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
||||||
<Trans i18nKey="cart:subtotal" />
|
<Trans i18nKey="cart:order.subtotal" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-[116px]">
|
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||||
<p className="text-sm">
|
<p className="text-sm text-right">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
value: cart.subtotal,
|
value: cart.subtotal,
|
||||||
currencyCode: cart.currency_code,
|
currencyCode: cart.currency_code,
|
||||||
@@ -94,14 +94,14 @@ export default function Cart({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-x-4 px-6 py-2">
|
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 py-2 sm:py-4">
|
||||||
<div className="mr-[36px]">
|
<div className="w-full sm:w-auto sm:mr-[42px]">
|
||||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
||||||
<Trans i18nKey="cart:promotionsTotal" />
|
<Trans i18nKey="cart:order.promotionsTotal" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-[116px]">
|
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||||
<p className="text-sm">
|
<p className="text-sm text-right">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
value: cart.discount_total,
|
value: cart.discount_total,
|
||||||
currencyCode: cart.currency_code,
|
currencyCode: cart.currency_code,
|
||||||
@@ -110,14 +110,14 @@ export default function Cart({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-x-4 px-6">
|
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6">
|
||||||
<div className="mr-[36px]">
|
<div className="w-full sm:w-auto sm:mr-[42px]">
|
||||||
<p className="ml-0 font-bold text-sm">
|
<p className="ml-0 font-bold text-sm">
|
||||||
<Trans i18nKey="cart:total" />
|
<Trans i18nKey="cart:order.total" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-[116px]">
|
<div className={`sm:mr-[112px] sm:w-[50px]`}>
|
||||||
<p className="text-sm">
|
<p className="text-sm text-right">
|
||||||
{formatCurrency({
|
{formatCurrency({
|
||||||
value: cart.total,
|
value: cart.total,
|
||||||
currencyCode: cart.currency_code,
|
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 && (
|
{IS_DISCOUNT_SHOWN && (
|
||||||
<Card
|
<Card
|
||||||
className="flex flex-col justify-between w-full sm:w-1/2"
|
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" />
|
<Trans i18nKey="cart:discountCode.title" />
|
||||||
</h5>
|
</h5>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="h-full">
|
||||||
<DiscountCode cart={{ ...cart }} />
|
<DiscountCode cart={{ ...cart }} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -154,7 +154,7 @@ export default function Cart({
|
|||||||
<Trans i18nKey="cart:locations.title" />
|
<Trans i18nKey="cart:locations.title" />
|
||||||
</h5>
|
</h5>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="h-full">
|
||||||
<AnalysisLocation cart={{ ...cart }} synlabAnalyses={synlabAnalyses} />
|
<AnalysisLocation cart={{ ...cart }} synlabAnalyses={synlabAnalyses} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const ComparePackagesModal = async ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={id}>
|
<TableRow key={id}>
|
||||||
<TableCell className="py-6">
|
<TableCell className="py-6 sm:max-w-[30vw]">
|
||||||
{title}{' '}
|
{title}{' '}
|
||||||
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
|
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -136,10 +136,10 @@ const ComparePackagesModal = async ({
|
|||||||
{isIncludedInStandard && <CheckWithBackground />}
|
{isIncludedInStandard && <CheckWithBackground />}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center" className="py-6">
|
<TableCell align="center" className="py-6">
|
||||||
{(isIncludedInStandard || isIncludedInStandardPlus) && <CheckWithBackground />}
|
{isIncludedInStandardPlus && <CheckWithBackground />}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center" className="py-6">
|
<TableCell align="center" className="py-6">
|
||||||
{(isIncludedInStandard || isIncludedInStandardPlus || isIncludedInPremium) && <CheckWithBackground />}
|
{isIncludedInPremium && <CheckWithBackground />}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Trans } from '@kit/ui/trans';
|
|||||||
|
|
||||||
export default function DashboardCards() {
|
export default function DashboardCards() {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 lg:px-4">
|
<div className="flex gap-4">
|
||||||
<Card
|
<Card
|
||||||
variant="gradient-success"
|
variant="gradient-success"
|
||||||
className="xs:w-1/2 sm:w-auto flex w-full flex-col justify-between"
|
className="xs:w-1/2 sm:w-auto flex w-full flex-col justify-between"
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
import { getPersonParameters } from '@kit/shared/utils';
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -30,7 +29,7 @@ import { cn } from '@kit/ui/utils';
|
|||||||
|
|
||||||
import { isNil } from 'lodash';
|
import { isNil } from 'lodash';
|
||||||
import { BmiCategory } from '~/lib/types/bmi';
|
import { BmiCategory } from '~/lib/types/bmi';
|
||||||
import {
|
import PersonalCode, {
|
||||||
bmiFromMetric,
|
bmiFromMetric,
|
||||||
getBmiBackgroundColor,
|
getBmiBackgroundColor,
|
||||||
getBmiStatus,
|
getBmiStatus,
|
||||||
@@ -60,7 +59,7 @@ const cards = ({
|
|||||||
}) => [
|
}) => [
|
||||||
{
|
{
|
||||||
title: 'dashboard:gender',
|
title: 'dashboard:gender',
|
||||||
description: gender ?? 'dashboard:male',
|
description: gender ?? '-',
|
||||||
icon: <User />,
|
icon: <User />,
|
||||||
iconBg: 'bg-success',
|
iconBg: 'bg-success',
|
||||||
},
|
},
|
||||||
@@ -84,7 +83,7 @@ const cards = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'dashboard:bmi',
|
title: 'dashboard:bmi',
|
||||||
description: bmiFromMetric(weight || 0, height || 0).toString(),
|
description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
|
||||||
icon: <TrendingUp />,
|
icon: <TrendingUp />,
|
||||||
iconBg: getBmiBackgroundColor(bmiStatus),
|
iconBg: getBmiBackgroundColor(bmiStatus),
|
||||||
},
|
},
|
||||||
@@ -145,21 +144,26 @@ export default function Dashboard({
|
|||||||
'id'
|
'id'
|
||||||
>[];
|
>[];
|
||||||
}) {
|
}) {
|
||||||
const params = getPersonParameters(account.personal_code!);
|
const height = account.accountParams?.height || 0;
|
||||||
const bmiStatus = getBmiStatus(bmiThresholds, {
|
const weight = account.accountParams?.weight || 0;
|
||||||
age: params?.age || 0,
|
|
||||||
height: account.accountParams?.height || 0,
|
let age: number = 0;
|
||||||
weight: account.accountParams?.weight || 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
||||||
{cards({
|
{cards({
|
||||||
gender: params?.gender,
|
gender: gender?.label,
|
||||||
age: params?.age,
|
age,
|
||||||
height: account.accountParams?.height,
|
height,
|
||||||
weight: account.accountParams?.weight,
|
weight,
|
||||||
bmiStatus,
|
bmiStatus,
|
||||||
smoking: account.accountParams?.isSmoker,
|
smoking: account.accountParams?.isSmoker,
|
||||||
}).map(
|
}).map(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { formatCurrency } from '@/packages/shared/src/utils';
|
|||||||
export type OrderAnalysisCard = Pick<
|
export type OrderAnalysisCard = Pick<
|
||||||
StoreProduct, 'title' | 'description' | 'subtitle'
|
StoreProduct, 'title' | 'description' | 'subtitle'
|
||||||
> & {
|
> & {
|
||||||
isAvailable: boolean;
|
|
||||||
variant: { id: string };
|
variant: { id: string };
|
||||||
price: number | null;
|
price: number | null;
|
||||||
};
|
};
|
||||||
@@ -58,13 +57,12 @@ export default function OrderAnalysesCards({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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(({
|
{analyses.map(({
|
||||||
title,
|
title,
|
||||||
variant,
|
variant,
|
||||||
description,
|
description,
|
||||||
subtitle,
|
subtitle,
|
||||||
isAvailable,
|
|
||||||
price,
|
price,
|
||||||
}) => {
|
}) => {
|
||||||
const formattedPrice = typeof price === 'number'
|
const formattedPrice = typeof price === 'number'
|
||||||
@@ -77,7 +75,7 @@ export default function OrderAnalysesCards({
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={title}
|
key={title}
|
||||||
variant={isAvailable ? "gradient-success" : "gradient-warning"}
|
variant="gradient-success"
|
||||||
className="flex flex-col justify-between"
|
className="flex flex-col justify-between"
|
||||||
>
|
>
|
||||||
<CardHeader className="flex-row">
|
<CardHeader className="flex-row">
|
||||||
@@ -86,46 +84,44 @@ export default function OrderAnalysesCards({
|
|||||||
>
|
>
|
||||||
<HeartPulse className="size-4 fill-green-500" />
|
<HeartPulse className="size-4 fill-green-500" />
|
||||||
</div>
|
</div>
|
||||||
{isAvailable && (
|
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
|
||||||
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
|
<Button
|
||||||
<Button
|
size="icon"
|
||||||
size="icon"
|
variant="outline"
|
||||||
variant="outline"
|
className="px-2 text-black"
|
||||||
className="px-2 text-black"
|
onClick={() => handleSelect(variant.id)}
|
||||||
onClick={() => handleSelect(variant.id)}
|
>
|
||||||
>
|
{variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
||||||
{variantAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="flex flex-col items-start gap-2">
|
<CardFooter className="flex gap-2">
|
||||||
<h5>
|
<div className="flex flex-col items-start gap-2 flex-1">
|
||||||
{title}
|
<h5>
|
||||||
{description && (
|
{title}
|
||||||
<>
|
{description && (
|
||||||
{' '}
|
<>
|
||||||
<InfoTooltip
|
{' '}
|
||||||
content={
|
<InfoTooltip
|
||||||
<div className='flex flex-col gap-2'>
|
content={
|
||||||
<span>{formattedPrice}</span>
|
<div className='flex flex-col gap-2'>
|
||||||
<span>{description}</span>
|
<span>{formattedPrice}</span>
|
||||||
</div>
|
<span>{description}</span>
|
||||||
}
|
</div>
|
||||||
/>
|
}
|
||||||
</>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h5>
|
||||||
|
{subtitle && (
|
||||||
|
<CardDescription>
|
||||||
|
{subtitle}
|
||||||
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</h5>
|
</div>
|
||||||
{isAvailable && subtitle && (
|
<div className="flex flex-col items-end gap-2 self-end text-sm">
|
||||||
<CardDescription>
|
<span>{formattedPrice}</span>
|
||||||
{subtitle}
|
</div>
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
{!isAvailable && (
|
|
||||||
<CardDescription>
|
|
||||||
<Trans i18nKey={'order-analysis:analysisNotAvailable'} />
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</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 flex-col gap-y-2 txt-medium text-ui-fg-subtle ">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="flex gap-x-1 items-center">
|
<span className="flex gap-x-1 items-center">
|
||||||
<Trans i18nKey="cart:orderConfirmed.subtotal" />
|
<Trans i18nKey="cart:order.subtotal" />
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="cart-subtotal" data-value={subtotal || 0}>
|
<span data-testid="cart-subtotal" data-value={subtotal || 0}>
|
||||||
{formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })}
|
{formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })}
|
||||||
@@ -32,7 +32,7 @@ export default function CartTotals({ medusaOrder }: {
|
|||||||
</div>
|
</div>
|
||||||
{!!discount_total && (
|
{!!discount_total && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span><Trans i18nKey="cart:orderConfirmed.discount" /></span>
|
<span><Trans i18nKey="cart:order.promotionsTotal" /></span>
|
||||||
<span
|
<span
|
||||||
className="text-ui-fg-interactive"
|
className="text-ui-fg-interactive"
|
||||||
data-testid="cart-discount"
|
data-testid="cart-discount"
|
||||||
@@ -43,17 +43,17 @@ export default function CartTotals({ medusaOrder }: {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
{/* <div className="flex justify-between">
|
||||||
<span className="flex gap-x-1 items-center ">
|
<span className="flex gap-x-1 items-center ">
|
||||||
<Trans i18nKey="cart:orderConfirmed.taxes" />
|
<Trans i18nKey="cart:orderConfirmed.taxes" />
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="cart-taxes" data-value={tax_total || 0}>
|
<span data-testid="cart-taxes" data-value={tax_total || 0}>
|
||||||
{formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })}
|
{formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div> */}
|
||||||
{!!gift_card_total && (
|
{!!gift_card_total && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span><Trans i18nKey="cart:orderConfirmed.giftCard" /></span>
|
<span><Trans i18nKey="cart:order.giftCard" /></span>
|
||||||
<span
|
<span
|
||||||
className="text-ui-fg-interactive"
|
className="text-ui-fg-interactive"
|
||||||
data-testid="cart-gift-card-amount"
|
data-testid="cart-gift-card-amount"
|
||||||
@@ -67,7 +67,7 @@ export default function CartTotals({ medusaOrder }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-px w-full border-b border-gray-200 my-4" />
|
<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 ">
|
<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
|
<span
|
||||||
className="txt-xlarge-plus"
|
className="txt-xlarge-plus"
|
||||||
data-testid="cart-total"
|
data-testid="cart-total"
|
||||||
|
|||||||
@@ -7,15 +7,23 @@ export default function OrderDetails({ order }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-2">
|
<div className="flex flex-col gap-y-2">
|
||||||
<span>
|
<div>
|
||||||
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{" "}
|
<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>
|
<span>
|
||||||
{formatDate(order.created_at, 'dd.MM.yyyy HH:mm')}
|
{formatDate(order.created_at, 'dd.MM.yyyy HH:mm')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<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";
|
import { loadCurrentUserAccount } from "../../_lib/server/load-user-account";
|
||||||
|
|
||||||
export async function logAnalysisResultsNavigateAction(analysisOrderId: string) {
|
export async function logAnalysisResultsNavigateAction(analysisOrderId: string) {
|
||||||
const account = await loadCurrentUserAccount();
|
const { account } = await loadCurrentUserAccount();
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found');
|
throw new Error('Account not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ async function analysesLoader() {
|
|||||||
const categoryProducts = category
|
const categoryProducts = category
|
||||||
? await listProducts({
|
? await listProducts({
|
||||||
countryCode,
|
countryCode,
|
||||||
queryParams: { limit: 100, category_id: category.id },
|
queryParams: { limit: 100, category_id: category.id, order: 'title' },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -51,8 +51,10 @@ async function analysesLoader() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
analyses:
|
analyses:
|
||||||
categoryProducts?.response.products.map<OrderAnalysisCard>(
|
categoryProducts?.response.products
|
||||||
({ title, description, subtitle, variants, status, metadata }) => {
|
.filter(({ status, metadata }) => status === 'published' && !!metadata?.analysisIdOriginal)
|
||||||
|
.map<OrderAnalysisCard>(
|
||||||
|
({ title, description, subtitle, variants }) => {
|
||||||
const variant = variants![0]!;
|
const variant = variants![0]!;
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -61,8 +63,6 @@ async function analysesLoader() {
|
|||||||
variant: {
|
variant: {
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
},
|
},
|
||||||
isAvailable:
|
|
||||||
status === 'published' && !!metadata?.analysisIdOriginal,
|
|
||||||
price: variant.calculated_price?.calculated_amount ?? null,
|
price: variant.calculated_price?.calculated_amount ?? null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import Isikukood, { Gender } from 'isikukood';
|
|
||||||
|
|
||||||
import { listProductTypes, listProducts } from "@lib/data/products";
|
import { listProductTypes, listProducts } from "@lib/data/products";
|
||||||
import { listRegions } from '@lib/data/regions';
|
import { listRegions } from '@lib/data/regions';
|
||||||
@@ -8,6 +7,7 @@ import type { StoreProduct } from '@medusajs/types';
|
|||||||
import { loadCurrentUserAccount } from './load-user-account';
|
import { loadCurrentUserAccount } from './load-user-account';
|
||||||
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
|
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
|
||||||
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
|
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
|
||||||
|
import PersonalCode from '~/lib/utils';
|
||||||
|
|
||||||
async function countryCodesLoader() {
|
async function countryCodesLoader() {
|
||||||
const countryCodes = await listRegions().then((regions) =>
|
const countryCodes = await listRegions().then((regions) =>
|
||||||
@@ -32,27 +32,8 @@ function userSpecificVariantLoader({
|
|||||||
if (!personalCode) {
|
if (!personalCode) {
|
||||||
throw new Error('Personal code not found');
|
throw new Error('Personal code not found');
|
||||||
}
|
}
|
||||||
const parsed = new Isikukood(personalCode);
|
|
||||||
const ageRange = (() => {
|
const { ageRange, gender: { value: gender } } = PersonalCode.parsePersonalCode(personalCode);
|
||||||
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';
|
|
||||||
|
|
||||||
return ({
|
return ({
|
||||||
product,
|
product,
|
||||||
@@ -89,6 +70,7 @@ async function analysisPackageElementsLoader({
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
id: analysisElementMedusaProductIds,
|
id: analysisElementMedusaProductIds,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
|
order: "title",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,8 +122,9 @@ async function analysisPackagesWithVariantLoader({
|
|||||||
return [
|
return [
|
||||||
...acc,
|
...acc,
|
||||||
{
|
{
|
||||||
|
variant,
|
||||||
variantId: variant.id,
|
variantId: variant.id,
|
||||||
nrOfAnalyses: getAnalysisElementMedusaProductIds([product]).length,
|
nrOfAnalyses: getAnalysisElementMedusaProductIds([{ ...product, variant }]).length,
|
||||||
price: variant.calculated_price?.calculated_amount ?? 0,
|
price: variant.calculated_price?.calculated_amount ?? 0,
|
||||||
title: product.title,
|
title: product.title,
|
||||||
subtitle: product.subtitle,
|
subtitle: product.subtitle,
|
||||||
@@ -158,7 +141,7 @@ async function analysisPackagesWithVariantLoader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analysisPackagesLoader() {
|
async function analysisPackagesLoader() {
|
||||||
const account = await loadCurrentUserAccount();
|
const { account } = await loadCurrentUserAccount();
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found');
|
throw new Error('Account not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ export const loadUserAccount = cache(accountLoader);
|
|||||||
|
|
||||||
export async function loadCurrentUserAccount() {
|
export async function loadCurrentUserAccount() {
|
||||||
const user = await requireUserInServerComponent();
|
const user = await requireUserInServerComponent();
|
||||||
return user?.identities?.[0]?.id
|
const userId = user?.id;
|
||||||
? await loadUserAccount(user?.identities?.[0]?.id)
|
if (!userId) {
|
||||||
: null;
|
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 client = getSupabaseServerClient();
|
||||||
const api = createAccountsApi(client);
|
const api = createAccountsApi(client);
|
||||||
|
|
||||||
return api.getAccount(accountId);
|
return api.getPersonalAccountByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,20 +28,15 @@ async function workspaceLoader() {
|
|||||||
|
|
||||||
const workspacePromise = api.getAccountWorkspace();
|
const workspacePromise = api.getAccountWorkspace();
|
||||||
|
|
||||||
// TODO!: remove before deploy to prod
|
const [accounts, workspace, user] = await Promise.all([
|
||||||
const tempAccountsPromise = () => api.loadTempUserAccounts();
|
|
||||||
|
|
||||||
const [accounts, workspace, user, tempVisibleAccounts] = await Promise.all([
|
|
||||||
accountsPromise(),
|
accountsPromise(),
|
||||||
workspacePromise,
|
workspacePromise,
|
||||||
requireUserInServerComponent(),
|
requireUserInServerComponent(),
|
||||||
tempAccountsPromise(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts,
|
accounts,
|
||||||
workspace,
|
workspace,
|
||||||
user,
|
user,
|
||||||
tempVisibleAccounts,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Trans } from 'react-i18next';
|
|||||||
import { AccountWithParams } from '@kit/accounts/api';
|
import { AccountWithParams } from '@kit/accounts/api';
|
||||||
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
|
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardTitle } from '@kit/ui/card';
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -25,7 +24,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@kit/ui/select';
|
} from '@kit/ui/select';
|
||||||
import { toast } from '@kit/ui/sonner';
|
import { toast } from '@kit/ui/sonner';
|
||||||
import { Switch } from '@kit/ui/switch';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AccountSettings,
|
AccountSettings,
|
||||||
@@ -131,7 +129,11 @@ export default function AccountSettingsForm({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input
|
||||||
|
placeholder="cm"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -150,7 +152,11 @@ export default function AccountSettingsForm({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input
|
||||||
|
placeholder="kg"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export const accountSettingsSchema = z.object({
|
|||||||
email: z.email({ error: 'error:invalidEmail' }).nullable(),
|
email: z.email({ error: 'error:invalidEmail' }).nullable(),
|
||||||
phone: z.e164({ error: 'error:invalidPhone' }),
|
phone: z.e164({ error: 'error:invalidPhone' }),
|
||||||
accountParams: z.object({
|
accountParams: z.object({
|
||||||
height: z.coerce.number({ error: 'error:invalidNumber' }),
|
height: z.coerce.number({ error: 'error:invalidNumber' }).gt(0),
|
||||||
weight: z.coerce.number({ error: 'error:invalidNumber' }),
|
weight: z.coerce.number({ error: 'error:invalidNumber' }).gt(0),
|
||||||
isSmoker: z.boolean().optional().nullable(),
|
isSmoker: z.boolean().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function PersonalAccountSettingsPage() {
|
async function PersonalAccountSettingsPage() {
|
||||||
const account = await loadCurrentUserAccount();
|
const { account } = await loadCurrentUserAccount();
|
||||||
return (
|
return (
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<div className="mx-auto w-full bg-white p-6">
|
<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 { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
|
||||||
import AccountPreferencesForm from '../_components/account-preferences-form';
|
import AccountPreferencesForm from '../_components/account-preferences-form';
|
||||||
import SettingsSectionHeader from '../_components/settings-section-header';
|
import SettingsSectionHeader from '../_components/settings-section-header';
|
||||||
|
|
||||||
export default async function PreferencesPage() {
|
export default async function PreferencesPage() {
|
||||||
const account = await loadCurrentUserAccount();
|
const { account } = await loadCurrentUserAccount();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full bg-white p-6">
|
<div className="mx-auto w-full bg-white p-6">
|
||||||
@@ -16,7 +12,6 @@ export default async function PreferencesPage() {
|
|||||||
titleKey="account:preferencesTabLabel"
|
titleKey="account:preferencesTabLabel"
|
||||||
descriptionKey="account:preferencesTabDescription"
|
descriptionKey="account:preferencesTabDescription"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountPreferencesForm account={account} />
|
<AccountPreferencesForm account={account} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ export const getAccountHealthDetailsFields = (
|
|||||||
>[],
|
>[],
|
||||||
members: Database['medreport']['Functions']['get_account_members']['Returns'],
|
members: Database['medreport']['Functions']['get_account_members']['Returns'],
|
||||||
): AccountHealthDetailsField[] => {
|
): AccountHealthDetailsField[] => {
|
||||||
const avarageWeight =
|
const averageWeight =
|
||||||
memberParams.reduce((sum, r) => sum + r.weight!, 0) / memberParams.length;
|
memberParams.reduce((sum, r) => sum + r.weight!, 0) / memberParams.length;
|
||||||
const avarageHeight =
|
const averageHeight =
|
||||||
memberParams.reduce((sum, r) => sum + r.height!, 0) / memberParams.length;
|
memberParams.reduce((sum, r) => sum + r.height!, 0) / memberParams.length;
|
||||||
const avarageAge =
|
const averageAge =
|
||||||
members.reduce((sum, r) => {
|
members.reduce((sum, r) => {
|
||||||
const person = new Isikukood(r.personal_code);
|
const person = new Isikukood(r.personal_code);
|
||||||
return sum + person.getAge();
|
return sum + person.getAge();
|
||||||
@@ -48,11 +48,11 @@ export const getAccountHealthDetailsFields = (
|
|||||||
const person = new Isikukood(r.personal_code);
|
const person = new Isikukood(r.personal_code);
|
||||||
return person.getGender() === 'female';
|
return person.getGender() === 'female';
|
||||||
}).length;
|
}).length;
|
||||||
const averageBMI = bmiFromMetric(avarageWeight, avarageHeight);
|
const averageBMI = bmiFromMetric(averageWeight, averageHeight);
|
||||||
const bmiStatus = getBmiStatus(bmiThresholds, {
|
const bmiStatus = getBmiStatus(bmiThresholds, {
|
||||||
age: avarageAge,
|
age: averageAge,
|
||||||
height: avarageHeight,
|
height: averageHeight,
|
||||||
weight: avarageWeight,
|
weight: averageWeight,
|
||||||
});
|
});
|
||||||
const malePercentage = members.length
|
const malePercentage = members.length
|
||||||
? (numberOfMaleMembers / members.length) * 100
|
? (numberOfMaleMembers / members.length) * 100
|
||||||
@@ -76,7 +76,7 @@ export const getAccountHealthDetailsFields = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'teams:healthDetails.avgAge',
|
title: 'teams:healthDetails.avgAge',
|
||||||
value: avarageAge.toFixed(0),
|
value: averageAge.toFixed(0),
|
||||||
Icon: Clock,
|
Icon: Clock,
|
||||||
iconBg: 'bg-success',
|
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 { createAccountsApi } from '@/packages/features/accounts/src/server/api';
|
||||||
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
|
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
|
||||||
|
|
||||||
@@ -12,8 +11,7 @@ export default async function HomeLayout({
|
|||||||
}) {
|
}) {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const user = await requireUserInServerComponent();
|
const { account, user } = await loadCurrentUserAccount();
|
||||||
const account = await loadCurrentUserAccount();
|
|
||||||
const api = createAccountsApi(client);
|
const api = createAccountsApi(client);
|
||||||
|
|
||||||
const hasAccountTeamMembership = await api.hasAccountTeamMembership(
|
const hasAccountTeamMembership = await api.hasAccountTeamMembership(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||||
import { Scale } from 'lucide-react';
|
import { Scale } from 'lucide-react';
|
||||||
@@ -27,6 +28,10 @@ async function SelectPackagePage() {
|
|||||||
const { analysisPackageElements, analysisPackages, countryCode } =
|
const { analysisPackageElements, analysisPackages, countryCode } =
|
||||||
await loadAnalysisPackages();
|
await loadAnalysisPackages();
|
||||||
|
|
||||||
|
if (analysisPackageElements.length === 0) {
|
||||||
|
return redirect(pathsConfig.app.home);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
|
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
|
||||||
<MedReportLogo />
|
<MedReportLogo />
|
||||||
|
|||||||
@@ -3,9 +3,26 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { createClient } from '@/utils/supabase/server';
|
import { createClient } from '@/utils/supabase/server';
|
||||||
|
import { medusaLogout } from '@lib/data/customer';
|
||||||
|
|
||||||
export const signOutAction = async () => {
|
export const signOutAction = async () => {
|
||||||
const supabase = await createClient();
|
const client = await createClient();
|
||||||
await supabase.auth.signOut();
|
|
||||||
|
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('/');
|
return redirect('/');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
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',
|
'booking',
|
||||||
'order-analysis-package',
|
'order-analysis-package',
|
||||||
'order-analysis',
|
'order-analysis',
|
||||||
|
'order-health-analysis',
|
||||||
'cart',
|
'cart',
|
||||||
'orders',
|
'orders',
|
||||||
'analysis-results',
|
'analysis-results',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Tables } from '@/packages/supabase/src/database.types';
|
import type { Tables } from '@/packages/supabase/src/database.types';
|
||||||
|
|
||||||
import { AccountWithParams } from '@kit/accounts/api';
|
|
||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-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;
|
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({
|
export async function getAccountAdmin({
|
||||||
primaryOwnerUserId,
|
primaryOwnerUserId,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Tables } from '@/packages/supabase/src/database.types';
|
|||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
import type { IUuringElement } from "./medipost.types";
|
import type { IUuringElement } from "./medipost.types";
|
||||||
|
|
||||||
type AnalysesWithGroupsAndElements = ({
|
export type AnalysesWithGroupsAndElements = ({
|
||||||
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
|
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
|
||||||
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
|
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()
|
const query = getSupabaseServerAdminClient()
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('analyses')
|
.from('analyses')
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { Database } from '@kit/supabase/database';
|
|||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
export enum NotificationAction {
|
export enum NotificationAction {
|
||||||
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
|
DOCTOR_NEW_JOBS = 'DOCTOR_NEW_JOBS',
|
||||||
NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
|
DOCTOR_PATIENT_RESULTS_RECEIVED = 'DOCTOR_PATIENT_RESULTS_RECEIVED',
|
||||||
PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
|
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 ({
|
export const createNotificationLog = async ({
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const createPageViewLog = async ({
|
|||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
action,
|
action,
|
||||||
changed_by: user.id,
|
changed_by: user.id,
|
||||||
extra_data: extraData,
|
|
||||||
})
|
})
|
||||||
.throwOnError();
|
.throwOnError();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type EmailTemplate = {
|
|||||||
subject: string;
|
subject: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
|
export type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
|
||||||
|
|
||||||
export const sendEmailFromTemplate = async <T>(
|
export const sendEmailFromTemplate = async <T>(
|
||||||
renderer: EmailRenderer<T>,
|
renderer: EmailRenderer<T>,
|
||||||
|
|||||||
@@ -5,23 +5,11 @@ import {
|
|||||||
createClient as createCustomClient,
|
createClient as createCustomClient,
|
||||||
} from '@supabase/supabase-js';
|
} 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 { SyncStatus } from '@/lib/types/audit';
|
||||||
import {
|
import {
|
||||||
AnalysisOrderStatus,
|
AnalysisOrderStatus,
|
||||||
GetMessageListResponse,
|
GetMessageListResponse,
|
||||||
IMedipostResponseXMLBase,
|
IMedipostResponseXMLBase,
|
||||||
MaterjalideGrupp,
|
|
||||||
MedipostAction,
|
MedipostAction,
|
||||||
MedipostOrderResponse,
|
MedipostOrderResponse,
|
||||||
MedipostPublicMessageResponse,
|
MedipostPublicMessageResponse,
|
||||||
@@ -32,12 +20,11 @@ import {
|
|||||||
import { toArray } from '@/lib/utils';
|
import { toArray } from '@/lib/utils';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { XMLParser } from 'fast-xml-parser';
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
import { uniqBy } from 'lodash';
|
|
||||||
|
|
||||||
import { Tables } from '@kit/supabase/database';
|
import { Tables } from '@kit/supabase/database';
|
||||||
import { createAnalysisGroup } from './analysis-group.service';
|
import { createAnalysisGroup } from './analysis-group.service';
|
||||||
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
|
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 { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
|
||||||
import { getAnalyses } from './analyses.service';
|
import { getAnalyses } from './analyses.service';
|
||||||
import { getAccountAdmin } from './account.service';
|
import { getAccountAdmin } from './account.service';
|
||||||
@@ -47,6 +34,7 @@ import { listRegions } from '@lib/data/regions';
|
|||||||
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
|
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
|
||||||
import { MedipostValidationError } from './medipost/MedipostValidationError';
|
import { MedipostValidationError } from './medipost/MedipostValidationError';
|
||||||
import { logMedipostDispatch } from './audit.service';
|
import { logMedipostDispatch } from './audit.service';
|
||||||
|
import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service';
|
||||||
|
|
||||||
const BASE_URL = process.env.MEDIPOST_URL!;
|
const BASE_URL = process.env.MEDIPOST_URL!;
|
||||||
const USER = process.env.MEDIPOST_USER!;
|
const USER = process.env.MEDIPOST_USER!;
|
||||||
@@ -206,12 +194,13 @@ export async function readPrivateMessageResponse({
|
|||||||
excludedMessageIds,
|
excludedMessageIds,
|
||||||
}: {
|
}: {
|
||||||
excludedMessageIds: string[];
|
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 messageId: string | null = null;
|
||||||
let hasAnalysisResponse = false;
|
let hasAnalysisResponse = false;
|
||||||
let hasPartialAnalysisResponse = false;
|
let hasPartialAnalysisResponse = false;
|
||||||
let hasFullAnalysisResponse = false;
|
let hasFullAnalysisResponse = false;
|
||||||
let medusaOrderId: string | undefined = undefined;
|
let medusaOrderId: string | undefined = undefined;
|
||||||
|
let analysisOrderId: number | undefined = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
|
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
|
||||||
@@ -224,6 +213,7 @@ export async function readPrivateMessageResponse({
|
|||||||
hasPartialAnalysisResponse: false,
|
hasPartialAnalysisResponse: false,
|
||||||
hasFullAnalysisResponse: false,
|
hasFullAnalysisResponse: false,
|
||||||
medusaOrderId: undefined,
|
medusaOrderId: undefined,
|
||||||
|
analysisOrderId: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,16 +222,15 @@ export async function readPrivateMessageResponse({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
|
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) {
|
if (hasInvalidOrderId || !messageResponse) {
|
||||||
await createMedipostActionLog({
|
await createMedipostActionLog({
|
||||||
action: 'sync_analysis_results_from_medipost',
|
action: 'sync_analysis_results_from_medipost',
|
||||||
xml: privateMessageXml,
|
xml: privateMessageXml,
|
||||||
hasAnalysisResults: false,
|
hasAnalysisResults: false,
|
||||||
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
messageId,
|
messageId,
|
||||||
@@ -249,12 +238,16 @@ export async function readPrivateMessageResponse({
|
|||||||
hasPartialAnalysisResponse: false,
|
hasPartialAnalysisResponse: false,
|
||||||
hasFullAnalysisResponse: false,
|
hasFullAnalysisResponse: false,
|
||||||
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
|
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'>;
|
let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
|
||||||
try {
|
try {
|
||||||
order = await getOrder({ medusaOrderId });
|
order = await getAnalysisOrder({ medusaOrderId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await deletePrivateMessage(privateMessage.messageId);
|
await deletePrivateMessage(privateMessage.messageId);
|
||||||
throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`);
|
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 });
|
const status = await syncPrivateMessage({ messageResponse, order });
|
||||||
|
|
||||||
if (status.isPartial) {
|
if (status.isPartial) {
|
||||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
|
await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
|
||||||
hasAnalysisResponse = true;
|
hasAnalysisResponse = true;
|
||||||
hasPartialAnalysisResponse = true;
|
hasPartialAnalysisResponse = true;
|
||||||
} else if (status.isCompleted) {
|
} else if (status.isCompleted) {
|
||||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' });
|
await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' });
|
||||||
await deletePrivateMessage(privateMessage.messageId);
|
await deletePrivateMessage(privateMessage.messageId);
|
||||||
hasAnalysisResponse = true;
|
hasAnalysisResponse = true;
|
||||||
hasFullAnalysisResponse = 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}`);
|
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(
|
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({
|
function getLatestMessage({
|
||||||
messages,
|
messages,
|
||||||
excludedMessageIds,
|
excludedMessageIds,
|
||||||
@@ -694,7 +571,7 @@ async function syncPrivateMessage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: allOrderResponseElements} = await supabase
|
const { data: allOrderResponseElements } = await supabase
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('analysis_response_elements')
|
.from('analysis_response_elements')
|
||||||
.select('*')
|
.select('*')
|
||||||
@@ -714,21 +591,37 @@ export async function sendOrderToMedipost({
|
|||||||
orderedAnalysisElements,
|
orderedAnalysisElements,
|
||||||
}: {
|
}: {
|
||||||
medusaOrderId: string;
|
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 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({
|
const orderXml = await composeOrderXML({
|
||||||
|
analyses,
|
||||||
|
analysisElements,
|
||||||
person: {
|
person: {
|
||||||
idCode: account.personal_code!,
|
idCode: account.personal_code!,
|
||||||
firstName: account.name ?? '',
|
firstName: account.name ?? '',
|
||||||
lastName: account.last_name ?? '',
|
lastName: account.last_name ?? '',
|
||||||
phone: account.phone ?? '',
|
phone: account.phone ?? '',
|
||||||
},
|
},
|
||||||
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
orderId: medreportOrder.id,
|
||||||
orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
|
||||||
orderId: medusaOrderId,
|
|
||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
comment: '',
|
comment: '',
|
||||||
});
|
});
|
||||||
@@ -780,7 +673,7 @@ export async function sendOrderToMedipost({
|
|||||||
hasAnalysisResults: false,
|
hasAnalysisResults: false,
|
||||||
medusaOrderId,
|
medusaOrderId,
|
||||||
});
|
});
|
||||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrderedAnalysisIds({
|
export async function getOrderedAnalysisIds({
|
||||||
@@ -826,7 +719,12 @@ export async function getOrderedAnalysisIds({
|
|||||||
throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`);
|
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) {
|
if (ids.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -867,10 +765,10 @@ export async function createMedipostActionLog({
|
|||||||
hasError = false,
|
hasError = false,
|
||||||
}: {
|
}: {
|
||||||
action:
|
action:
|
||||||
| 'send_order_to_medipost'
|
| 'send_order_to_medipost'
|
||||||
| 'sync_analysis_results_from_medipost'
|
| 'sync_analysis_results_from_medipost'
|
||||||
| 'send_fake_analysis_results_to_medipost'
|
| 'send_fake_analysis_results_to_medipost'
|
||||||
| 'send_analysis_results_to_medipost';
|
| 'send_analysis_results_to_medipost';
|
||||||
xml: string;
|
xml: string;
|
||||||
hasAnalysisResults?: boolean;
|
hasAnalysisResults?: boolean;
|
||||||
medusaOrderId?: string | null;
|
medusaOrderId?: string | null;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function composeOrderTestResponseXML({
|
|||||||
};
|
};
|
||||||
orderedAnalysisElementsIds: number[];
|
orderedAnalysisElementsIds: number[];
|
||||||
orderedAnalysesIds: number[];
|
orderedAnalysesIds: number[];
|
||||||
orderId: string;
|
orderId: number;
|
||||||
orderCreatedAt: Date;
|
orderCreatedAt: Date;
|
||||||
}) {
|
}) {
|
||||||
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
|
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
|
||||||
@@ -100,7 +100,7 @@ export async function composeOrderTestResponseXML({
|
|||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
|
<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>
|
<Vastus>
|
||||||
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
||||||
${getClientInstitution({ index: 1 })}
|
${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;
|
countryCode: string;
|
||||||
}) {
|
}) {
|
||||||
const supabase = getSupabaseServerClient();
|
const supabase = getSupabaseServerClient();
|
||||||
const user = await requireUserInServerComponent();
|
const { account, user } = await loadCurrentUserAccount();
|
||||||
const account = await loadCurrentUserAccount();
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found');
|
throw new Error('Account not found');
|
||||||
}
|
}
|
||||||
@@ -70,8 +69,7 @@ export async function handleDeleteCartItem({ lineId }: { lineId: string }) {
|
|||||||
|
|
||||||
const supabase = getSupabaseServerClient();
|
const supabase = getSupabaseServerClient();
|
||||||
const cartId = await getCartId();
|
const cartId = await getCartId();
|
||||||
const user = await requireUserInServerComponent();
|
const { account, user } = await loadCurrentUserAccount();
|
||||||
const account = await loadCurrentUserAccount();
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found');
|
throw new Error('Account not found');
|
||||||
}
|
}
|
||||||
@@ -96,8 +94,7 @@ export async function handleNavigateToPayment({
|
|||||||
paymentSessionId: string;
|
paymentSessionId: string;
|
||||||
}) {
|
}) {
|
||||||
const supabase = getSupabaseServerClient();
|
const supabase = getSupabaseServerClient();
|
||||||
const user = await requireUserInServerComponent();
|
const { account, user } = await loadCurrentUserAccount();
|
||||||
const account = await loadCurrentUserAccount();
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found');
|
throw new Error('Account not found');
|
||||||
}
|
}
|
||||||
@@ -137,8 +134,7 @@ export async function handleLineItemTimeout({
|
|||||||
lineItem: StoreCartLineItem;
|
lineItem: StoreCartLineItem;
|
||||||
}) {
|
}) {
|
||||||
const supabase = getSupabaseServerClient();
|
const supabase = getSupabaseServerClient();
|
||||||
const user = await requireUserInServerComponent();
|
const { account, user } = await loadCurrentUserAccount();
|
||||||
const account = await loadCurrentUserAccount();
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('Account not found');
|
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 type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>;
|
||||||
|
|
||||||
export async function createOrder({
|
export async function createAnalysisOrder({
|
||||||
medusaOrder,
|
medusaOrder,
|
||||||
orderedAnalysisElements,
|
orderedAnalysisElements,
|
||||||
}: {
|
}: {
|
||||||
@@ -38,7 +38,7 @@ export async function createOrder({
|
|||||||
return orderResult.data.id;
|
return orderResult.data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateOrder({
|
export async function updateAnalysisOrder({
|
||||||
orderId,
|
orderId,
|
||||||
orderStatus,
|
orderStatus,
|
||||||
}: {
|
}: {
|
||||||
@@ -56,7 +56,7 @@ export async function updateOrder({
|
|||||||
.throwOnError();
|
.throwOnError();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateOrderStatus({
|
export async function updateAnalysisOrderStatus({
|
||||||
orderId,
|
orderId,
|
||||||
medusaOrderId,
|
medusaOrderId,
|
||||||
orderStatus,
|
orderStatus,
|
||||||
@@ -80,12 +80,12 @@ export async function updateOrderStatus({
|
|||||||
.throwOnError();
|
.throwOnError();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrder({
|
export async function getAnalysisOrder({
|
||||||
medusaOrderId,
|
medusaOrderId,
|
||||||
orderId,
|
analysisOrderId,
|
||||||
}: {
|
}: {
|
||||||
medusaOrderId?: string;
|
medusaOrderId?: string;
|
||||||
orderId?: number;
|
analysisOrderId?: number;
|
||||||
}) {
|
}) {
|
||||||
const query = getSupabaseServerAdminClient()
|
const query = getSupabaseServerAdminClient()
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
@@ -93,15 +93,15 @@ export async function getOrder({
|
|||||||
.select('*')
|
.select('*')
|
||||||
if (medusaOrderId) {
|
if (medusaOrderId) {
|
||||||
query.eq('medusa_order_id', medusaOrderId);
|
query.eq('medusa_order_id', medusaOrderId);
|
||||||
} else if (orderId) {
|
} else if (analysisOrderId) {
|
||||||
query.eq('id', orderId);
|
query.eq('id', analysisOrderId);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Either medusaOrderId or orderId must be provided');
|
throw new Error('Either medusaOrderId or orderId must be provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: order, error } = await query.single();
|
const { data: order, error } = await query.single();
|
||||||
if (error) {
|
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;
|
return order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import Isikukood, { Gender } from 'isikukood';
|
|
||||||
import { Tables } from '@/packages/supabase/src/database.types';
|
import { Tables } from '@/packages/supabase/src/database.types';
|
||||||
import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants';
|
import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants';
|
||||||
|
import PersonalCode from '../utils';
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
export const getPais = (
|
export const getPais = (
|
||||||
sender: string,
|
sender: string,
|
||||||
recipient: string,
|
recipient: string,
|
||||||
createdAt: Date,
|
orderId: number,
|
||||||
orderId: string,
|
|
||||||
packageName = "OL",
|
packageName = "OL",
|
||||||
) => {
|
) => {
|
||||||
if (isProd) {
|
if (isProd) {
|
||||||
@@ -19,7 +18,7 @@ export const getPais = (
|
|||||||
<Pakett versioon="20">${packageName}</Pakett>
|
<Pakett versioon="20">${packageName}</Pakett>
|
||||||
<Saatja>${sender}</Saatja>
|
<Saatja>${sender}</Saatja>
|
||||||
<Saaja>${recipient}</Saaja>
|
<Saaja>${recipient}</Saaja>
|
||||||
<Aeg>${format(createdAt, DATE_TIME_FORMAT)}</Aeg>
|
<Aeg>${format(new Date(), DATE_TIME_FORMAT)}</Aeg>
|
||||||
<SaadetisId>${orderId}</SaadetisId>
|
<SaadetisId>${orderId}</SaadetisId>
|
||||||
<Email>info@medreport.ee</Email>
|
<Email>info@medreport.ee</Email>
|
||||||
</Pais>`;
|
</Pais>`;
|
||||||
@@ -73,15 +72,15 @@ export const getPatient = ({
|
|||||||
lastName: string,
|
lastName: string,
|
||||||
firstName: string,
|
firstName: string,
|
||||||
}) => {
|
}) => {
|
||||||
const isikukood = new Isikukood(idCode);
|
const { dob, gender } = PersonalCode.parsePersonalCode(idCode);
|
||||||
return `<Patsient>
|
return `<Patsient>
|
||||||
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
|
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
|
||||||
<Isikukood>${idCode}</Isikukood>
|
<Isikukood>${idCode}</Isikukood>
|
||||||
<PerekonnaNimi>${lastName}</PerekonnaNimi>
|
<PerekonnaNimi>${lastName}</PerekonnaNimi>
|
||||||
<EesNimi>${firstName}</EesNimi>
|
<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>
|
<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>`;
|
</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) {
|
export function toTitleCase(str?: string) {
|
||||||
if (!str) return '';
|
return (
|
||||||
return str.replace(
|
str
|
||||||
/\w\S*/g,
|
?.toLowerCase()
|
||||||
(text: string) =>
|
.replace(/[^-'’\s]+/g, (match) =>
|
||||||
text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
match.replace(/^./, (first) => first.toUpperCase()),
|
||||||
|
) ?? ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +41,12 @@ export function sortByDate<T>(
|
|||||||
|
|
||||||
export const bmiFromMetric = (kg: number, cm: number) => {
|
export const bmiFromMetric = (kg: number, cm: number) => {
|
||||||
const m = cm / 100;
|
const m = cm / 100;
|
||||||
const bmi = kg / (m * m);
|
const m2 = m * m;
|
||||||
return bmi ? Math.round(bmi) : NaN;
|
if (m2 === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bmi = kg / m2;
|
||||||
|
return !Number.isNaN(bmi) ? Math.round(bmi) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getBmiStatus(
|
export function getBmiStatus(
|
||||||
@@ -58,7 +63,9 @@ export function getBmiStatus(
|
|||||||
) || null;
|
) || null;
|
||||||
const bmi = bmiFromMetric(params.weight, params.height);
|
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.obesity_min) return BmiCategory.OBESE;
|
||||||
if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT;
|
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) {
|
type AgeRange = '18-29' | '30-39' | '40-49' | '50-59' | '60';
|
||||||
const person = new Isikukood(personalCode);
|
export default class PersonalCode {
|
||||||
if (person.getGender() === Gender.FEMALE) return 'common:female';
|
static getPersonalCode(personalCode: string | null) {
|
||||||
if (person.getGender() === Gender.MALE) return 'common:male';
|
if (!personalCode) {
|
||||||
return 'common:unknown';
|
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) {
|
export async function middleware(request: NextRequest) {
|
||||||
const secureHeaders = await createResponseWithSecureHeaders();
|
const secureHeaders = await createResponseWithSecureHeaders();
|
||||||
const response = NextResponse.next(secureHeaders);
|
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
|
// set a unique request ID for each request
|
||||||
// this helps us log and trace requests
|
// this helps us log and trace requests
|
||||||
@@ -35,6 +37,10 @@ export async function middleware(request: NextRequest) {
|
|||||||
// apply CSRF protection for mutating requests
|
// apply CSRF protection for mutating requests
|
||||||
const csrfResponse = await withCsrfMiddleware(request, response);
|
const csrfResponse = await withCsrfMiddleware(request, response);
|
||||||
|
|
||||||
|
if (lang) {
|
||||||
|
csrfResponse.cookies.set('lang', lang);
|
||||||
|
}
|
||||||
|
|
||||||
// handle patterns for specific routes
|
// handle patterns for specific routes
|
||||||
const handlePattern = matchUrlPattern(request.url);
|
const handlePattern = matchUrlPattern(request.url);
|
||||||
|
|
||||||
@@ -176,6 +182,14 @@ function getPatterns() {
|
|||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL(pathsConfig.app.home, req.nextUrl.origin).href,
|
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",
|
"fast-xml-parser": "^5.2.5",
|
||||||
"isikukood": "3.1.7",
|
"isikukood": "3.1.7",
|
||||||
"jsonwebtoken": "9.0.2",
|
"jsonwebtoken": "9.0.2",
|
||||||
|
"libphonenumber-js": "^1.12.15",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
"next": "15.3.2",
|
"next": "15.3.2",
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"cssnano": "^7.0.7",
|
"cssnano": "^7.0.7",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "13.0.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"supabase": "^2.30.4",
|
"supabase": "^2.30.4",
|
||||||
"tailwindcss": "4.1.7",
|
"tailwindcss": "4.1.7",
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import { SupabaseClient } from '@supabase/supabase-js';
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import {
|
|
||||||
renderAllResultsReceivedEmail,
|
|
||||||
renderFirstResultsReceivedEmail,
|
|
||||||
} from '@kit/email-templates';
|
|
||||||
import { Database } from '@kit/supabase/database';
|
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';
|
import { RecordChange, Tables } from '../record-change.type';
|
||||||
|
|
||||||
export function createDatabaseWebhookRouterService(
|
export function createDatabaseWebhookRouterService(
|
||||||
@@ -113,58 +100,13 @@ class DatabaseWebhookRouterService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let action;
|
const { createAnalysisOrderWebhooksService } = await import(
|
||||||
try {
|
'@kit/notifications/webhooks/analysis-order-notifications.service'
|
||||||
const data = {
|
);
|
||||||
analysisOrderId: record.id,
|
|
||||||
language: 'et',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') {
|
const service = createAnalysisOrderWebhooksService();
|
||||||
action = NotificationAction.NEW_JOBS_ALERT;
|
|
||||||
|
|
||||||
const doctorAccounts = await getDoctorAccounts();
|
return service.handleStatusChangeWebhook(record);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,33 +49,28 @@ export async function renderAccountDeleteEmail(props: Props) {
|
|||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{previewText}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:hello`, {
|
{t(`${namespace}:hello`, {
|
||||||
displayName: props.userDisplayName,
|
displayName: props.userDisplayName,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:paragraph1`, {
|
{t(`${namespace}:paragraph1`, {
|
||||||
productName: props.productName,
|
productName: props.productName,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:paragraph2`)}
|
{t(`${namespace}:paragraph2`)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:paragraph3`, {
|
{t(`${namespace}:paragraph3`, {
|
||||||
productName: props.productName,
|
productName: props.productName,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:paragraph4`, {
|
{t(`${namespace}:paragraph4`, {
|
||||||
productName: props.productName,
|
productName: props.productName,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Preview,
|
Preview,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
Text,
|
||||||
render
|
render,
|
||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
|
|
||||||
import { BodyStyle } from '../components/body-style';
|
import { BodyStyle } from '../components/body-style';
|
||||||
@@ -46,11 +46,10 @@ export async function renderAllResultsReceivedEmail({
|
|||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{previewText}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:hello`)}
|
{t(`${namespace}:hello`)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -62,7 +61,6 @@ export async function renderAllResultsReceivedEmail({
|
|||||||
>
|
>
|
||||||
{t(`${namespace}:linkText`)}
|
{t(`${namespace}:linkText`)}
|
||||||
</EmailButton>
|
</EmailButton>
|
||||||
|
|
||||||
<Text>
|
<Text>
|
||||||
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
|||||||
@@ -55,23 +55,19 @@ export async function renderCompanyOfferEmail({
|
|||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{previewText}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:companyName`)} {companyData.companyName}
|
{t(`${namespace}:companyName`)} {companyData.companyName}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:contactPerson`)} {companyData.contactPerson}
|
{t(`${namespace}:contactPerson`)} {companyData.contactPerson}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:email`)} {companyData.email}
|
{t(`${namespace}:email`)} {companyData.email}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:phone`)} {companyData.phone || 'N/A'}
|
{t(`${namespace}:phone`)} {companyData.phone || 'N/A'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Head,
|
Head,
|
||||||
Html,
|
Html,
|
||||||
|
Link,
|
||||||
Preview,
|
Preview,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
Text,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
import { BodyStyle } from '../components/body-style';
|
import { BodyStyle } from '../components/body-style';
|
||||||
import CommonFooter from '../components/common-footer';
|
import CommonFooter from '../components/common-footer';
|
||||||
import { EmailContent } from '../components/content';
|
import { EmailContent } from '../components/content';
|
||||||
import { EmailButton } from '../components/email-button';
|
|
||||||
import { EmailHeader } from '../components/header';
|
import { EmailHeader } from '../components/header';
|
||||||
import { EmailHeading } from '../components/heading';
|
import { EmailHeading } from '../components/heading';
|
||||||
import { EmailWrapper } from '../components/wrapper';
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
@@ -20,12 +20,10 @@ import { initializeEmailI18n } from '../lib/i18n';
|
|||||||
export async function renderDoctorSummaryReceivedEmail({
|
export async function renderDoctorSummaryReceivedEmail({
|
||||||
language,
|
language,
|
||||||
recipientName,
|
recipientName,
|
||||||
orderNr,
|
|
||||||
analysisOrderId,
|
analysisOrderId,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
orderNr: string;
|
|
||||||
analysisOrderId: number;
|
analysisOrderId: number;
|
||||||
}) {
|
}) {
|
||||||
const namespace = 'doctor-summary-received-email';
|
const namespace = 'doctor-summary-received-email';
|
||||||
@@ -35,13 +33,9 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
namespace: [namespace, 'common'],
|
namespace: [namespace, 'common'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewText = t(`${namespace}:previewText`, {
|
const previewText = t(`${namespace}:previewText`);
|
||||||
orderNr,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subject = t(`${namespace}:subject`, {
|
const subject = t(`${namespace}:subject`);
|
||||||
orderNr,
|
|
||||||
});
|
|
||||||
|
|
||||||
const html = await render(
|
const html = await render(
|
||||||
<Html>
|
<Html>
|
||||||
@@ -54,29 +48,26 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{previewText}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:hello`, {
|
{t(`common:helloName`, { name: recipientName })}
|
||||||
displayName: recipientName,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
|
||||||
{t(`${namespace}:summaryReceivedForOrder`, { orderNr })}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<EmailButton
|
|
||||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
|
||||||
>
|
|
||||||
{t(`${namespace}:linkText`, { orderNr })}
|
|
||||||
</EmailButton>
|
|
||||||
<Text>
|
<Text>
|
||||||
{t(`${namespace}:ifButtonDisabled`)}{' '}
|
{t(`${namespace}:p1`)}{' '}
|
||||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
<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>
|
||||||
|
<Text>{t(`${namespace}:p2`)}</Text>
|
||||||
|
<Text>{t(`${namespace}:p3`)}</Text>
|
||||||
|
<Text>{t(`${namespace}:p4`)}</Text>
|
||||||
|
|
||||||
<CommonFooter t={t} />
|
<CommonFooter t={t} />
|
||||||
</EmailContent>
|
</EmailContent>
|
||||||
</EmailWrapper>
|
</EmailWrapper>
|
||||||
|
|||||||
@@ -46,11 +46,10 @@ export async function renderFirstResultsReceivedEmail({
|
|||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{previewText}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:hello`)}
|
{t(`${namespace}:hello`)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -74,20 +74,17 @@ export async function renderInviteEmail(props: Props) {
|
|||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{heading}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{heading}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{hello}
|
{hello}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
className="text-[16px] leading-[24px] text-[#242424]"
|
className="text-[16px] leading-[24px] text-[#242424]"
|
||||||
dangerouslySetInnerHTML={{ __html: mainText }}
|
dangerouslySetInnerHTML={{ __html: mainText }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{props.teamLogo && (
|
{props.teamLogo && (
|
||||||
<Section>
|
<Section>
|
||||||
<Row>
|
<Row>
|
||||||
@@ -102,20 +99,16 @@ export async function renderInviteEmail(props: Props) {
|
|||||||
</Row>
|
</Row>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
<Section className="mt-[32px] mb-[32px] text-center">
|
||||||
<Section className="mb-[32px] mt-[32px] text-center">
|
|
||||||
<CtaButton href={props.link}>{joinTeam}</CtaButton>
|
<CtaButton href={props.link}>{joinTeam}</CtaButton>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:copyPasteLink`)}{' '}
|
{t(`${namespace}:copyPasteLink`)}{' '}
|
||||||
<Link href={props.link} className="text-blue-600 no-underline">
|
<Link href={props.link} className="text-blue-600 no-underline">
|
||||||
{props.link}
|
{props.link}
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
|
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
|
||||||
|
|
||||||
<Text className="text-[12px] leading-[24px] text-[#666666]">
|
<Text className="text-[12px] leading-[24px] text-[#666666]">
|
||||||
{t(`${namespace}:invitationIntendedFor`, {
|
{t(`${namespace}:invitationIntendedFor`, {
|
||||||
invitedUserEmail: props.invitedUserEmail,
|
invitedUserEmail: props.invitedUserEmail,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Preview,
|
Preview,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
Text,
|
||||||
render
|
render,
|
||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
|
|
||||||
import { BodyStyle } from '../components/body-style';
|
import { BodyStyle } from '../components/body-style';
|
||||||
@@ -50,11 +50,10 @@ export async function renderNewJobsAvailableEmail({
|
|||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{previewText}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:hello`)}
|
{t(`${namespace}:hello`)}
|
||||||
</Text>
|
</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>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{heading}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{heading}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] text-[#242424]">{mainText}</Text>
|
<Text className="text-[16px] text-[#242424]">{mainText}</Text>
|
||||||
|
|
||||||
<Text className="text-[16px] text-[#242424]">{otpText}</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'}>
|
<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}
|
{props.otp}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -70,15 +70,13 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
|
|||||||
<Tailwind>
|
<Tailwind>
|
||||||
<Body>
|
<Body>
|
||||||
<EmailWrapper>
|
<EmailWrapper>
|
||||||
<EmailHeader>
|
|
||||||
<EmailHeading>{heading}</EmailHeading>
|
|
||||||
</EmailHeader>
|
|
||||||
|
|
||||||
<EmailContent>
|
<EmailContent>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{heading}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{hello}
|
{hello}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{lines.map((line, index) => (
|
{lines.map((line, index) => (
|
||||||
<Text
|
<Text
|
||||||
key={index}
|
key={index}
|
||||||
@@ -86,7 +84,6 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
|
|||||||
dangerouslySetInnerHTML={{ __html: line }}
|
dangerouslySetInnerHTML={{ __html: line }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<CommonFooter t={t} />
|
<CommonFooter t={t} />
|
||||||
</EmailContent>
|
</EmailContent>
|
||||||
</EmailWrapper>
|
</EmailWrapper>
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ export * from './emails/doctor-summary-received.email';
|
|||||||
export * from './emails/new-jobs-available.email';
|
export * from './emails/new-jobs-available.email';
|
||||||
export * from './emails/first-results-received.email';
|
export * from './emails/first-results-received.email';
|
||||||
export * from './emails/all-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",
|
"subject": "Doctor's summary has arrived",
|
||||||
"previewText": "A doctor has submitted feedback on your analysis results.",
|
"previewText": "The doctor has prepared a summary of the test results.",
|
||||||
"hello": "Hello {{displayName}},",
|
"p1": "The doctor's summary has arrived:",
|
||||||
"summaryReceivedForOrder": "A doctor has submitted feedback to your analysis results from order {{orderNr}}.",
|
"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.",
|
||||||
"linkText": "View summary",
|
"p3": "MedReport makes it easy, convenient, and fast to view health data in one place and order health check-ups.",
|
||||||
"ifButtonDisabled": "If clicking the button does not work, copy this link to your browser's url field:"
|
"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>",
|
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
|
||||||
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
|
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
|
||||||
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</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}}",
|
"subject": "Arsti kokkuvõte on saabunud",
|
||||||
"previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.",
|
"previewText": "Arst on koostanud kokkuvõte analüüsitulemustele.",
|
||||||
"hello": "Tere, {{displayName}}",
|
"p1": "Arsti kokkuvõte on saabunud:",
|
||||||
"summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.",
|
"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.",
|
||||||
"linkText": "Vaata kokkuvõtet",
|
"p3": "MedReport aitab lihtsalt, mugavalt ja kiirelt terviseandmeid ühest kohast vaadata ning tellida terviseuuringuid.",
|
||||||
"ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:"
|
"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}}",
|
"subject": "Заключение врача готово",
|
||||||
"previewText": "Врач отправил заключение по вашим результатам анализа.",
|
"previewText": "Врач подготовил заключение по результатам анализов.",
|
||||||
"hello": "Здравствуйте, {{displayName}}",
|
"p1": "Заключение врача готово:",
|
||||||
"summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.",
|
"p2": "Рекомендуется проходить комплексное обследование регулярно, но как минимум один раз в год, если вы хотите сохранить активный и полноценный образ жизни.",
|
||||||
"linkText": "Посмотреть заключение",
|
"p3": "MedReport позволяет легко, удобно и быстро просматривать медицинские данные в одном месте и заказывать обследования.",
|
||||||
"ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:"
|
"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 { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||||
|
|
||||||
const signedInAsLabel = useMemo(() => {
|
const { name, last_name } = personalAccountData ?? {};
|
||||||
const email = user?.email ?? undefined;
|
const firstNameLabel = toTitleCase(name) ?? '-';
|
||||||
const phone = user?.phone ?? undefined;
|
const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
|
||||||
|
|
||||||
return email ?? phone;
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const displayName =
|
|
||||||
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
|
|
||||||
|
|
||||||
const hasTotpFactor = useMemo(() => {
|
const hasTotpFactor = useMemo(() => {
|
||||||
const factors = user?.factors ?? [];
|
const factors = user?.factors ?? [];
|
||||||
@@ -128,7 +122,7 @@ export function PersonalAccountDropdown({
|
|||||||
<ProfileAvatar
|
<ProfileAvatar
|
||||||
className={'rounded-md'}
|
className={'rounded-md'}
|
||||||
fallbackClassName={'rounded-md border'}
|
fallbackClassName={'rounded-md border'}
|
||||||
displayName={displayName ?? user?.email ?? ''}
|
displayName={firstNameLabel}
|
||||||
pictureUrl={personalAccountData?.picture_url}
|
pictureUrl={personalAccountData?.picture_url}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -142,7 +136,7 @@ export function PersonalAccountDropdown({
|
|||||||
data-test={'account-dropdown-display-name'}
|
data-test={'account-dropdown-display-name'}
|
||||||
className={'truncate text-sm'}
|
className={'truncate text-sm'}
|
||||||
>
|
>
|
||||||
{toTitleCase(displayName)}
|
{firstNameLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className={'block truncate'}>{signedInAsLabel}</span>
|
<span className={'block truncate'}>{fullNameLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -265,11 +265,13 @@ function FactorQrCode({
|
|||||||
z.object({
|
z.object({
|
||||||
factorName: z.string().min(1),
|
factorName: z.string().min(1),
|
||||||
qrCode: z.string().min(1),
|
qrCode: z.string().min(1),
|
||||||
|
totpSecret: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
factorName: '',
|
factorName: '',
|
||||||
qrCode: '',
|
qrCode: '',
|
||||||
|
totpSecret: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,6 +321,7 @@ function FactorQrCode({
|
|||||||
if (data.type === 'totp') {
|
if (data.type === 'totp') {
|
||||||
form.setValue('factorName', name);
|
form.setValue('factorName', name);
|
||||||
form.setValue('qrCode', data.totp.qr_code);
|
form.setValue('qrCode', data.totp.qr_code);
|
||||||
|
form.setValue('totpSecret', data.totp.secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatch event to set factor ID
|
// dispatch event to set factor ID
|
||||||
@@ -331,7 +334,7 @@ function FactorQrCode({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
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>
|
<p>
|
||||||
@@ -343,6 +346,10 @@ function FactorQrCode({
|
|||||||
<div className={'flex justify-center'}>
|
<div className={'flex justify-center'}>
|
||||||
<QrImage src={form.getValues('qrCode')} />
|
<QrImage src={form.getValues('qrCode')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className='text-center text-sm'>
|
||||||
|
{form.getValues('totpSecret')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SupabaseClient } from '@supabase/supabase-js';
|
|||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
|
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
|
||||||
|
import PersonalCode from '~/lib/utils';
|
||||||
|
|
||||||
export type AccountWithParams =
|
export type AccountWithParams =
|
||||||
Database['medreport']['Tables']['accounts']['Row'] & {
|
Database['medreport']['Tables']['accounts']['Row'] & {
|
||||||
@@ -48,6 +49,33 @@ class AccountsApi {
|
|||||||
return data;
|
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
|
* @name getAccountWorkspace
|
||||||
* @description Get the account workspace data.
|
* @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