Merge branch 'develop' of https://github.com/MR-medreport/MRB2B into MED-103

This commit is contained in:
Helena
2025-09-11 10:09:37 +03:00
164 changed files with 3059 additions and 1158 deletions

View File

@@ -13,10 +13,8 @@ import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { featureFlagsConfig } from '@kit/shared/config';
import { pathsConfig } from '@kit/shared/config';
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { useAuthConfig } from '@kit/shared/hooks';
const ModeToggle = dynamic(() =>
import('@kit/ui/mode-toggle').then((mod) => ({
@@ -60,6 +58,8 @@ export function SiteHeaderAccountSection({
}
function AuthButtons() {
const { config } = useAuthConfig();
return (
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
<div className={'hidden md:flex'}>
@@ -68,19 +68,25 @@ function AuthButtons() {
</If>
</div>
<div className={'flex gap-x-2.5'}>
<Button className={'block'} asChild variant={'ghost'}>
<Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth:signIn'} />
</Link>
</Button>
{config && (
<div className={'flex gap-x-2.5'}>
{(config.providers.password || config.providers.oAuth.length > 0) && (
<Button className={'block'} asChild variant={'ghost'}>
<Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth:signIn'} />
</Link>
</Button>
)}
<Button asChild className="text-xs md:text-sm" variant={'default'}>
<Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth:signUp'} />
</Link>
</Button>
</div>
{config.providers.password && (
<Button asChild className="text-xs md:text-sm" variant={'default'}>
<Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth:signUp'} />
</Link>
</Button>
)}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import Link from 'next/link';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { pathsConfig } from '@kit/shared/config';
import { ArrowRightIcon } from 'lucide-react';
import { CtaButton, Hero } from '@kit/ui/marketing';
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
return (
<div className={'flex space-x-4'}>
<CtaButton>
<Link href={'/auth/sign-up'}>
<Link href={pathsConfig.auth.signUp}>
<span className={'flex items-center space-x-0.5'}>
<span>
<Trans i18nKey={'common:getStarted'} />

View File

@@ -6,11 +6,12 @@ type ProcessedMessage = {
hasPartialAnalysisResponse: boolean;
hasFullAnalysisResponse: boolean;
medusaOrderId: string | undefined;
analysisOrderId: number | undefined;
};
type GroupedResults = {
processed: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
processed: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
};
export default async function syncAnalysisResults() {
@@ -37,14 +38,14 @@ export default async function syncAnalysisResults() {
}
const groupedResults = processedMessages.reduce((acc, result) => {
if (result.medusaOrderId) {
if (result.analysisOrderId) {
if (result.hasAnalysisResponse) {
if (!acc.processed) {
acc.processed = [];
}
acc.processed.push({
messageId: result.messageId,
medusaOrderId: result.medusaOrderId,
analysisOrderId: result.analysisOrderId,
});
} else {
if (!acc.waitingForResults) {
@@ -52,7 +53,7 @@ export default async function syncAnalysisResults() {
}
acc.waitingForResults.push({
messageId: result.messageId,
medusaOrderId: result.medusaOrderId,
analysisOrderId: result.analysisOrderId,
});
}
}

View File

@@ -23,7 +23,7 @@ export const POST = async (request: NextRequest) => {
'Successfully sent out open job notification emails to doctors.',
);
await createNotificationLog({
action: NotificationAction.NEW_JOBS_ALERT,
action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'SUCCESS',
});
return NextResponse.json(
@@ -39,7 +39,7 @@ export const POST = async (request: NextRequest) => {
e,
);
await createNotificationLog({
action: NotificationAction.NEW_JOBS_ALERT,
action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'FAIL',
comment: e?.message,
});

View File

@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
},
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId,
orderId: medreportOrder.id,
orderCreatedAt: new Date(medreportOrder.created_at),
});

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getOrder } from "~/lib/services/order.service";
import { getAnalysisOrder } from "~/lib/services/order.service";
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from "~/lib/services/account.service";
@@ -14,9 +14,9 @@ export async function POST(request: Request) {
const { medusaOrderId } = await request.json();
const medusaOrder = await retrieveOrder(medusaOrderId)
const medreportOrder = await getOrder({ medusaOrderId });
const analysisOrder = await getAnalysisOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const account = await getAccountAdmin({ primaryOwnerUserId: analysisOrder.user_id });
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
@@ -29,8 +29,8 @@ export async function POST(request: Request) {
},
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
orderId: analysisOrder.id,
orderCreatedAt: new Date(analysisOrder.created_at),
});
try {

View File

@@ -1,19 +1,62 @@
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
import { createAuthCallbackService } from '@kit/supabase/auth';
import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
const ERROR_PATH = '/auth/callback/error';
const redirectOnError = (searchParams?: string) => {
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const error = searchParams.get('error');
if (error) {
const { searchParams } = getErrorURLParameters({ error });
return redirectOnError(searchParams);
}
const authCode = searchParams.get('code');
if (!authCode) {
return redirectOnError();
}
let redirectPath = searchParams.get('next') || pathsConfig.app.home;
// if we have an invite token, we redirect to the join team page
// instead of the default next url. This is because the user is trying
// to join a team and we want to make sure they are redirected to the
// correct page.
const inviteToken = searchParams.get('invite_token');
if (inviteToken) {
const urlParams = new URLSearchParams({
invite_token: inviteToken,
email: searchParams.get('email') ?? '',
});
redirectPath = `${pathsConfig.app.joinTeam}?${urlParams.toString()}`;
}
const service = createAuthCallbackService(getSupabaseServerClient());
const oauthResult = await service.exchangeCodeForSession(authCode);
if (!("isSuccess" in oauthResult)) {
return redirectOnError(oauthResult.searchParams);
}
const { nextPath } = await service.exchangeCodeForSession(request, {
joinTeamPath: pathsConfig.app.joinTeam,
redirectPath: pathsConfig.app.home,
});
const api = createAccountsApi(getSupabaseServerClient());
return redirect(nextPath);
const account = await api.getPersonalAccountByUserId(
oauthResult.user.id,
);
if (!account.email || !account.name || !account.last_name) {
return redirect(pathsConfig.auth.updateAccount);
}
return redirect(redirectPath);
}

View File

@@ -5,7 +5,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient());

View 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>
</>
);
}

View 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 />;
}

View File

@@ -1,14 +1,9 @@
import Link from 'next/link';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { authConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
import PasswordOption from './components/PasswordOption';
interface SignInPageProps {
searchParams: Promise<{
@@ -26,47 +21,26 @@ export const generateMetadata = async () => {
};
async function SignInPage({ searchParams }: SignInPageProps) {
const { invite_token: inviteToken, next = pathsConfig.app.home } =
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
await searchParams;
const signUpPath =
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
const authConfig = await getServerAuthConfig();
const paths = {
callback: pathsConfig.auth.callback,
returnPath: next ?? pathsConfig.app.home,
joinTeam: pathsConfig.app.joinTeam,
updateAccount: pathsConfig.auth.updateAccount,
};
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth:signInHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth:signInSubheading'} />
</p>
</div>
<SignInMethodsContainer
if (authConfig.providers.password) {
return (
<PasswordOption
inviteToken={inviteToken}
paths={paths}
returnPath={returnPath}
providers={authConfig.providers}
/>
);
}
<div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}>
<Link href={signUpPath} prefetch={true}>
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
</Link>
</Button>
</div>
</>
);
if (authConfig.providers.oAuth.includes('keycloak')) {
return <SignInPageClientRedirect />;
}
return null;
}
export default withI18n(SignInPage);

View File

@@ -1,7 +1,8 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { authConfig, pathsConfig } from '@kit/shared/config';
import { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
@@ -37,6 +38,12 @@ async function SignUpPage({ searchParams }: Props) {
pathsConfig.auth.signIn +
(inviteToken ? `?invite_token=${inviteToken}` : '');
const authConfig = await getServerAuthConfig();
if (!authConfig.providers.password) {
return redirect('/');
}
return (
<>
<div className={'flex flex-col items-center gap-1'}>
@@ -50,8 +57,7 @@ async function SignUpPage({ searchParams }: Props) {
</div>
<SignUpMethodsContainer
providers={authConfig.providers}
displayTermsCheckbox={authConfig.displayTermsCheckbox}
authConfig={authConfig}
inviteToken={inviteToken}
paths={paths}
/>

View File

@@ -1,8 +1,9 @@
'use client';
import Link from 'next/link';
import { User } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import { ExternalLink } from '@/public/assets/external-link';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -21,40 +22,87 @@ import {
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
import { UpdateAccountSchemaClient } from '../_lib/schemas/update-account.schema';
import { onUpdateAccount } from '../_lib/server/update-account';
import { toast } from '@kit/ui/sonner';
import { pathsConfig } from '@/packages/shared/src/config';
type UpdateAccountFormValues = z.infer<ReturnType<typeof UpdateAccountSchemaClient>>;
export function UpdateAccountForm({
defaultValues,
isEmailUser,
}: {
defaultValues: UpdateAccountFormValues,
isEmailUser: boolean,
}) {
const router = useRouter();
const { t } = useTranslation('account');
export function UpdateAccountForm({ user }: { user: User }) {
const form = useForm({
resolver: zodResolver(UpdateAccountSchema),
resolver: zodResolver(UpdateAccountSchemaClient({ isEmailUser })),
mode: 'onChange',
defaultValues: {
firstName: '',
lastName: '',
personalCode: '',
email: user.email,
phone: '',
city: '',
weight: 0,
height: 0,
userConsent: false,
},
defaultValues,
});
const { firstName, lastName, personalCode, email, userConsent } = defaultValues;
const defaultValues_weight = "weight" in defaultValues ? defaultValues.weight : null;
const defaultValues_height = "height" in defaultValues ? defaultValues.height : null;
const hasFirstName = !!firstName;
const hasLastName = !!lastName;
const hasPersonalCode = !!personalCode;
const hasEmail = !!email;
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) => {
const loading = toast.loading(t('updateAccount.updateAccountLoading'));
try {
const response = await onUpdateAccount({
firstName: values.firstName || firstName,
lastName: values.lastName || lastName,
personalCode: values.personalCode || personalCode,
email: values.email || email,
phone: values.phone,
weight: ((("weight" in values && values.weight) ?? defaultValues_weight) || null) as number,
height: ((("height" in values && values.height) ?? defaultValues_height) || null) as number,
userConsent: values.userConsent ?? userConsent,
city: values.city,
});
if (!response) {
throw new Error('Failed to update account');
}
toast.dismiss(loading);
toast.success(t('updateAccount.updateAccountSuccess'));
if (response.hasUnseenMembershipConfirmation) {
router.push(pathsConfig.auth.membershipConfirmation);
} else {
router.push(pathsConfig.app.selectPackage);
}
} catch (error) {
console.info("promiseresult error", error);
toast.error(t('updateAccount.updateAccountError'));
toast.dismiss(loading);
}
};
return (
<Form {...form}>
<form
className="flex flex-col gap-6 px-6 pt-10 text-left"
onSubmit={form.handleSubmit(onUpdateAccount)}
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
>
<FormField
name="firstName"
disabled={hasFirstName && !isEmailUser}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:firstName'} />
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} autoFocus={!hasFirstName} />
</FormControl>
<FormMessage />
</FormItem>
@@ -63,13 +111,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="lastName"
disabled={hasLastName && !isEmailUser}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:lastName'} />
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} autoFocus={hasFirstName && !hasLastName} />
</FormControl>
<FormMessage />
</FormItem>
@@ -78,6 +127,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="personalCode"
disabled={hasPersonalCode && !isEmailUser}
render={({ field }) => (
<FormItem>
<FormLabel>
@@ -93,13 +143,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="email"
disabled={hasEmail}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:email'} />
</FormLabel>
<FormControl>
<Input {...field} disabled />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -121,72 +172,76 @@ export function UpdateAccountForm({ user }: { user: User }) {
)}
/>
<FormField
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:city'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!isEmailUser && (
<>
<FormField
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:city'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-between gap-4">
<FormField
name="weight"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:weight'} />
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="kg"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-between gap-4">
<FormField
name="weight"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:weight'} />
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="kg"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="height"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:height'} />
</FormLabel>
<FormControl>
<Input
placeholder="cm"
type="number"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
name="height"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:height'} />
</FormLabel>
<FormControl>
<Input
placeholder="cm"
type="number"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)}
<FormField
name="userConsent"

View File

@@ -1,6 +1,8 @@
import { z } from 'zod';
import Isikukood from 'isikukood';
import parsePhoneNumber from 'libphonenumber-js/min';
export const UpdateAccountSchema = z.object({
const updateAccountSchema = {
firstName: z
.string({
error: 'First name is required',
@@ -10,20 +12,42 @@ export const UpdateAccountSchema = z.object({
.string({
error: 'Last name is required',
})
.nonempty(),
personalCode: z
.string({
error: 'Personal code is required',
})
.nonempty(),
.nonempty({
error: 'common:formFieldError.stringNonEmpty',
}),
personalCode: z.string().refine(
(val) => {
try {
return new Isikukood(val).validate();
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPersonalCode',
},
),
email: z.string().email({
message: 'Email is required',
}),
phone: z
.string({
error: 'Phone number is required',
error: 'error:invalidPhone',
})
.nonempty(),
.nonempty()
.refine(
(phone) => {
try {
const phoneNumber = parsePhoneNumber(phone);
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPhoneNumber',
}
),
city: z.string().optional(),
weight: z
.number({
@@ -45,4 +69,34 @@ export const UpdateAccountSchema = z.object({
userConsent: z.boolean().refine((val) => val === true, {
message: 'Must be true',
}),
} as const;
export const UpdateAccountSchemaServer = z.object({
firstName: updateAccountSchema.firstName,
lastName: updateAccountSchema.lastName,
personalCode: updateAccountSchema.personalCode,
email: updateAccountSchema.email,
phone: updateAccountSchema.phone,
city: updateAccountSchema.city,
weight: updateAccountSchema.weight.nullable(),
height: updateAccountSchema.height.nullable(),
userConsent: updateAccountSchema.userConsent,
});
export const UpdateAccountSchemaClient = ({ isEmailUser }: { isEmailUser: boolean }) => z.object({
firstName: updateAccountSchema.firstName,
lastName: updateAccountSchema.lastName,
personalCode: updateAccountSchema.personalCode,
email: updateAccountSchema.email,
phone: updateAccountSchema.phone,
...(isEmailUser
? {
city: z.string().optional(),
weight: z.number().optional(),
height: z.number().optional(),
}
: {
city: updateAccountSchema.city,
weight: updateAccountSchema.weight,
height: updateAccountSchema.height,
}),
userConsent: updateAccountSchema.userConsent,
});

View File

@@ -1,7 +1,5 @@
'use server';
import { redirect } from 'next/navigation';
import { updateCustomer } from '@lib/data/customer';
import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
@@ -10,8 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
import { UpdateAccountSchema } from '../schemas/update-account.schema';
import { UpdateAccountSchemaServer } from '../schemas/update-account.schema';
export const onUpdateAccount = enhanceAction(
async (params: AccountSubmitData) => {
@@ -28,22 +25,23 @@ export const onUpdateAccount = enhanceAction(
console.warn('On update account error: ', err);
}
await updateCustomer({
first_name: params.firstName,
last_name: params.lastName,
phone: params.phone,
});
try {
await updateCustomer({
first_name: params.firstName,
last_name: params.lastName,
phone: params.phone,
});
} catch (e) {
console.error("Failed to update Medusa customer", e);
}
const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation();
if (hasUnseenMembershipConfirmation) {
redirect(pathsConfig.auth.membershipConfirmation);
} else {
redirect(pathsConfig.app.selectPackage);
return {
hasUnseenMembershipConfirmation,
}
},
{
schema: UpdateAccountSchema,
schema: UpdateAccountSchemaServer,
},
);

View File

@@ -1,7 +1,6 @@
import { redirect } from 'next/navigation';
import { signOutAction } from '@/lib/actions/sign-out';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { BackButton } from '@kit/shared/components/back-button';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
@@ -11,18 +10,36 @@ import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { UpdateAccountForm } from './_components/update-account-form';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { toTitleCase } from '~/lib/utils';
async function UpdateAccount() {
const client = getSupabaseServerClient();
const { account, user } = await loadCurrentUserAccount();
const {
data: { user },
} = await client.auth.getUser();
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
const isEmailUser = user?.app_metadata?.provider === 'email';
if (!user) {
redirect(pathsConfig.auth.signIn);
}
const defaultValues = {
firstName: account?.name ? toTitleCase(account.name) : '',
lastName: account?.last_name ? toTitleCase(account.last_name) : '',
personalCode: account?.personal_code ?? '',
email: (() => {
if (isKeycloakUser) {
return account?.email ?? '';
}
return account?.email ?? user?.email ?? '';
})(),
phone: account?.phone ?? '+372',
city: account?.city ?? '',
weight: account?.accountParams?.weight ?? 0,
height: account?.accountParams?.height ?? 0,
userConsent: account?.has_consent_personal_data ?? false,
};
return (
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
@@ -34,7 +51,7 @@ async function UpdateAccount() {
<p className="text-muted-foreground pt-1 text-sm">
<Trans i18nKey={'account:updateAccount:description'} />
</p>
<UpdateAccountForm user={user} />
<UpdateAccountForm defaultValues={defaultValues} isEmailUser={isEmailUser} />
</div>
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
</div>

View File

@@ -1,4 +1,5 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
import { pathsConfig } from '@kit/shared/config';
@@ -22,14 +23,15 @@ export default async function AnalysisResultsPage({
id: string;
}>;
}) {
const account = await loadCurrentUserAccount();
const { id: analysisOrderId } = await params;
const { id: analysisResponseId } = await params;
const [{ account }, analysisResponse] = await Promise.all([
loadCurrentUserAccount(),
loadUserAnalysis(Number(analysisOrderId)),
]);
const analysisResponse = await loadUserAnalysis(Number(analysisResponseId));
if (!account?.id || !analysisResponse) {
return null;
if (!account?.id) {
return redirect("/");
}
await createPageViewLog({
@@ -37,6 +39,19 @@ export default async function AnalysisResultsPage({
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
});
if (!analysisResponse) {
return (
<>
<PageHeader
title={<Trans i18nKey="analysis-results:pageTitle" />}
description={<Trans i18nKey="analysis-results:descriptionEmpty" />}
/>
<PageBody className="gap-4">
</PageBody>
</>
);
}
return (
<>
<PageHeader />

View File

@@ -28,9 +28,13 @@ function BookingPage() {
return (
<>
<AppBreadcrumbs />
<h3 className="mt-8">
<HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />}
description={<Trans i18nKey={'booking:description'} />}
/>
<h4 className="mt-8">
<Trans i18nKey="booking:noCategories" />
</h3>
</h4>
</>
);
}

View File

@@ -7,13 +7,15 @@ import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-
import { listProductTypes } from "@lib/data/products";
import { placeOrder, retrieveCart } from "@lib/data/cart";
import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
import { createOrder } from '~/lib/services/order.service';
import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service';
import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
import { createNotificationsApi } from '@kit/notifications/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { AccountWithParams } from '@kit/accounts/api';
import type { AccountWithParams } from '@kit/accounts/api';
import type { StoreOrder } from '@medusajs/types';
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
const ANALYSIS_TYPE_HANDLE = 'synlab-analysis';
const MONTONIO_PAID_STATUS = 'PAID';
const env = () => z
@@ -28,24 +30,27 @@ const env = () => z
error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
isEnabledDispatchOnMontonioCallback: z
.boolean({
error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required',
}),
})
.parse({
emailSender: process.env.EMAIL_SENDER,
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
isEnabledDispatchOnMontonioCallback: process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true',
});
const sendEmail = async ({
account,
email,
analysisPackageName,
personName,
partnerLocationName,
language,
}: {
account: AccountWithParams,
account: Pick<AccountWithParams, 'name' | 'id'>,
email: string,
analysisPackageName: string,
personName: string,
partnerLocationName: string,
language: string,
}) => {
@@ -58,7 +63,7 @@ const sendEmail = async ({
const { html, subject } = await renderSynlabAnalysisPackageEmail({
analysisPackageName,
personName,
personName: account.name,
partnerLocationName,
language,
});
@@ -83,9 +88,7 @@ const sendEmail = async ({
}
}
export async function processMontonioCallback(orderToken: string) {
const { language } = await createI18nServerInstance();
async function decodeOrderToken(orderToken: string) {
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
const decoded = jwt.verify(orderToken, secretKey, {
@@ -96,54 +99,120 @@ export async function processMontonioCallback(orderToken: string) {
throw new Error("Payment not successful");
}
const account = await loadCurrentUserAccount();
return decoded;
}
async function getCartByOrderToken(decoded: MontonioOrderToken) {
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
if (!cartId) {
throw new Error("Cart ID not found");
}
const cart = await retrieveCart(cartId);
if (!cart) {
throw new Error("Cart not found");
}
return cart;
}
async function getOrderResultParameters(medusaOrder: StoreOrder) {
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
const analysisType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE);
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
const analysisItems = medusaOrder.items?.filter(({ product_type_id }) => product_type_id === analysisType?.id);
return {
medusaOrderId: medusaOrder.id,
email: medusaOrder.email,
analysisPackageOrder: analysisPackageOrderItem
? {
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
analysisPackageName: analysisPackageOrderItem?.title ?? '',
}
: null,
analysisItemsOrder: Array.isArray(analysisItems) && analysisItems.length > 0
? analysisItems.map(({ product }) => ({
analysisName: product?.title ?? '',
analysisId: product?.metadata?.analysisIdOriginal as string ?? '',
}))
: null,
};
}
async function sendAnalysisPackageOrderEmail({
account,
email,
analysisPackageOrder,
}: {
account: AccountWithParams,
email: string,
analysisPackageOrder: {
partnerLocationName: string,
analysisPackageName: string,
},
}) {
const { language } = await createI18nServerInstance();
const { analysisPackageName, partnerLocationName } = analysisPackageOrder;
try {
await sendEmail({
account: { id: account.id, name: account.name },
email,
analysisPackageName,
partnerLocationName,
language,
});
} catch (error) {
console.error("Failed to send email", error);
}
}
export async function processMontonioCallback(orderToken: string) {
const { account } = await loadCurrentUserAccount();
if (!account) {
throw new Error("Account not found in context");
}
try {
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
if (!cartId) {
throw new Error("Cart ID not found");
}
const decoded = await decodeOrderToken(orderToken);
const cart = await getCartByOrderToken(decoded);
const cart = await retrieveCart(cartId);
if (!cart) {
throw new Error("Cart not found");
}
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false });
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
try {
const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id });
console.info(`Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`);
return { success: true, orderId: existingAnalysisOrder.id };
} catch {
// ignored
}
const orderResult = {
medusaOrderId: medusaOrder.id,
email: medusaOrder.email,
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
analysisPackageName: analysisPackageOrderItem?.title ?? '',
orderedAnalysisElements,
};
const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements });
const orderResult = await getOrderResultParameters(medusaOrder);
const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult;
const personName = account.name;
const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult;
if (email && analysisPackageName) {
try {
await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language });
} catch (error) {
console.error("Failed to send email", error);
if (email) {
if (analysisPackageOrder) {
await sendAnalysisPackageOrderEmail({ account, email, analysisPackageOrder });
} else {
console.info(`Order has no analysis package, skipping email.`);
}
if (analysisItemsOrder) {
// @TODO send email for separate analyses
console.warn(`Order has analysis items, but no email template exists yet`);
} else {
console.info(`Order has no analysis items, skipping email.`);
}
} else {
// @TODO send email for separate analyses
console.error("Missing email or analysisPackageName", orderResult);
console.error("Missing email to send order result email", orderResult);
}
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
if (env().isEnabledDispatchOnMontonioCallback) {
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
}
return { success: true, orderId };
} catch (error) {

View File

@@ -8,6 +8,7 @@ import Cart from '../../_components/cart';
import { listProductTypes } from '@lib/data/products';
import CartTimer from '../../_components/cart/cart-timer';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -17,9 +18,9 @@ export async function generateMetadata() {
};
}
export default async function CartPage() {
async function CartPage() {
const cart = await retrieveCart().catch((error) => {
console.error(error);
console.error("Failed to retrieve cart", error);
return notFound();
});
@@ -50,3 +51,5 @@ export default async function CartPage() {
</PageBody>
);
}
export default withI18n(CartPage);

View File

@@ -18,7 +18,7 @@ export const generateMetadata = async () => {
};
async function OrderAnalysisPage() {
const account = await loadCurrentUserAccount();
const { account } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}

View File

@@ -21,6 +21,9 @@ async function OrderHealthAnalysisPage() {
description={<Trans i18nKey={'order-health-analysis:description'} />}
/>
<PageBody>
<h4 className="mt-8">
<Trans i18nKey="booking:noCategories" />
</h4>
</PageBody>
</>
);

View File

@@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getOrder } from '~/lib/services/order.service';
import { getAnalysisOrder } from '~/lib/services/order.service';
import { retrieveOrder } from '@lib/data/orders';
import { pathsConfig } from '@kit/shared/config';
@@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: {
}) {
const params = await props.params;
const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null);
const order = await getAnalysisOrder({ analysisOrderId: Number(params.orderId) }).catch(() => null);
if (!order) {
redirect(pathsConfig.app.myOrders);
}

View File

@@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getOrder } from '~/lib/services/order.service';
import { getAnalysisOrder } from '~/lib/services/order.service';
import { retrieveOrder } from '@lib/data/orders';
import { pathsConfig } from '@kit/shared/config';
@@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: {
}) {
const params = await props.params;
const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null);
const order = await getAnalysisOrder({ analysisOrderId: Number(params.orderId) }).catch(() => null);
if (!order) {
redirect(pathsConfig.app.myOrders);
}

View File

@@ -61,6 +61,11 @@ async function OrdersPage() {
</React.Fragment>
)
})}
{analysisOrders.length === 0 && (
<h5 className="mt-6">
<Trans i18nKey="orders:noOrders" />
</h5>
)}
</PageBody>
</>
);

View File

@@ -26,8 +26,8 @@ export const generateMetadata = async () => {
async function UserHomePage() {
const client = getSupabaseServerClient();
const account = await loadCurrentUserAccount();
const api = await createAccountsApi(client);
const { account } = await loadCurrentUserAccount();
const api = createAccountsApi(client);
const bmiThresholds = await api.fetchBmiThresholds();
if (!account) {

View File

@@ -55,11 +55,15 @@ export default function AnalysisLocation({ cart, synlabAnalyses }: { cart: Store
}
return (
<div className="w-full bg-white flex flex-col txt-medium gap-y-2">
<div className="w-full h-full bg-white flex flex-col txt-medium gap-y-4">
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:locations.description'} />
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => onSubmit(data))}
className="w-full mb-2 flex gap-x-2"
className="w-full mb-2 flex gap-x-2 flex-1"
>
<Select
value={form.watch('locationId')}
@@ -106,11 +110,6 @@ export default function AnalysisLocation({ cart, synlabAnalyses }: { cart: Store
</p>
</div>
)}
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:locations.description'} />
</p>
</div>
)
}

View File

@@ -17,7 +17,7 @@ export default function CartItem({ item, currencyCode }: {
return (
<TableRow className="w-full" data-testid="product-row">
<TableCell className="text-left w-[100%] px-6">
<TableCell className="text-left w-[100%] px-4 sm:px-6">
<p
className="txt-medium-plus text-ui-fg-base"
data-testid="product-title"
@@ -26,11 +26,11 @@ export default function CartItem({ item, currencyCode }: {
</p>
</TableCell>
<TableCell className="px-6">
<TableCell className="px-4 sm:px-6">
{item.quantity}
</TableCell>
<TableCell className="min-w-[80px] px-6">
<TableCell className="min-w-[80px] px-4 sm:px-6">
{formatCurrency({
value: item.unit_price,
currencyCode,
@@ -38,7 +38,7 @@ export default function CartItem({ item, currencyCode }: {
})}
</TableCell>
<TableCell className="min-w-[80px] px-6">
<TableCell className="min-w-[80px] px-4 sm:px-6 text-right">
{formatCurrency({
value: item.total,
currencyCode,
@@ -46,7 +46,7 @@ export default function CartItem({ item, currencyCode }: {
})}
</TableCell>
<TableCell className="text-right px-6">
<TableCell className="text-right px-4 sm:px-6">
<span className="flex gap-x-1 justify-end w-[60px]">
<CartItemDelete id={item.id} />
</span>

View File

@@ -22,19 +22,19 @@ export default function CartItems({ cart, items, productColumnLabelKey }: {
<Table className="rounded-lg border border-separate">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-6">
<TableHead className="px-4 sm:px-6">
<Trans i18nKey={productColumnLabelKey} />
</TableHead>
<TableHead className="px-6">
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.quantity" />
</TableHead>
<TableHead className="px-6 min-w-[100px]">
<TableHead className="px-4 sm:px-6 min-w-[100px]">
<Trans i18nKey="cart:table.price" />
</TableHead>
<TableHead className="px-6 min-w-[100px]">
<TableHead className="px-4 sm:px-6 min-w-[100px] text-right">
<Trans i18nKey="cart:table.total" />
</TableHead>
<TableHead className="px-6">
<TableHead className="px-4 sm:px-6">
</TableHead>
</TableRow>
</TableHeader>

View 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' };
}
}

View File

@@ -2,9 +2,8 @@
import { Badge, Text } from "@medusajs/ui"
import { toast } from '@kit/ui/sonner';
import React, { useActionState } from "react";
import React from "react";
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
import { convertToLocale } from "@lib/util/money"
import { StoreCart, StorePromotion } from "@medusajs/types"
import Trash from "@modules/common/icons/trash"
@@ -16,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { addPromotionCodeAction, removePromotionCodeAction } from "./discount-code-actions";
const DiscountCodeSchema = z.object({
code: z.string().min(1),
@@ -31,42 +31,35 @@ export default function DiscountCode({ cart }: {
const { promotions = [] } = cart;
const removePromotionCode = async (code: string) => {
const validPromotions = promotions.filter(
(promotion) => promotion.code !== code,
)
const appliedCodes = promotions
.filter((p) => p.code !== undefined)
.map((p) => p.code!)
await applyPromotions(
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!),
{
onSuccess: () => {
toast.success(t('cart:discountCode.removeSuccess'));
},
onError: () => {
toast.error(t('cart:discountCode.removeError'));
},
}
)
const loading = toast.loading(t('cart:discountCode.removeLoading'));
const result = await removePromotionCodeAction(code, appliedCodes)
toast.dismiss(loading);
if (result.success) {
toast.success(t('cart:discountCode.removeSuccess'));
} else {
toast.error(t('cart:discountCode.removeError'));
}
}
const addPromotionCode = async (code: string) => {
const codes = promotions
.filter((p) => p.code === undefined)
.map((p) => p.code!)
codes.push(code.toString())
const loading = toast.loading(t('cart:discountCode.addLoading'));
const result = await addPromotionCodeAction(code)
await applyPromotions(codes, {
onSuccess: () => {
toast.success(t('cart:discountCode.addSuccess'));
},
onError: () => {
toast.error(t('cart:discountCode.addError'));
},
});
form.reset()
toast.dismiss(loading);
if (result.success) {
toast.success(t('cart:discountCode.addSuccess'));
form.reset()
} else {
toast.error(t('cart:discountCode.addError'));
}
}
const [message, formAction] = useActionState(submitPromotionForm, null)
const form = useForm<z.infer<typeof DiscountCodeSchema>>({
defaultValues: {
@@ -76,11 +69,15 @@ export default function DiscountCode({ cart }: {
});
return (
<div className="w-full bg-white flex flex-col txt-medium">
<div className="w-full h-full bg-white flex flex-col txt-medium gap-y-4">
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:discountCode.subtitle'} />
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
className="w-full mb-2 flex gap-x-2 sm:flex-row flex-col gap-y-2"
className="w-full mb-2 flex gap-x-2 sm:flex-row flex-col gap-y-2 flex-1"
>
<FormField
name={'code'}
@@ -96,14 +93,14 @@ export default function DiscountCode({ cart }: {
<Button
type="submit"
variant="secondary"
className="h-full"
className="h-min"
>
<Trans i18nKey={'cart:discountCode.apply'} />
</Button>
</form>
</Form>
{promotions.length > 0 ? (
{promotions.length > 0 && (
<div className="w-full flex items-center mt-4">
<div className="flex flex-col w-full gap-y-2">
<p>
@@ -117,12 +114,12 @@ export default function DiscountCode({ cart }: {
className="flex items-center justify-between w-full max-w-full mb-2"
data-testid="discount-row"
>
<Text className="flex gap-x-1 items-baseline txt-small-plus w-4/5 pr-1">
<Text className="flex gap-x-1 items-baseline text-sm w-4/5 pr-1">
<span className="truncate" data-testid="discount-code">
<Badge
color={promotion.is_automatic ? "green" : "grey"}
size="small"
className="px-4"
className="px-4 text-sm"
>
{promotion.code}
</Badge>{" "}
@@ -135,7 +132,7 @@ export default function DiscountCode({ cart }: {
"percentage"
? `${promotion.application_method.value}%`
: convertToLocale({
amount: promotion.application_method.value,
amount: Number(promotion.application_method.value),
currency_code:
promotion.application_method
.currency_code,
@@ -173,10 +170,6 @@ export default function DiscountCode({ cart }: {
})}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:discountCode.subtitle'} />
</p>
)}
</div>
)

View File

@@ -78,14 +78,14 @@ export default function Cart({
</div>
{hasCartItems && (
<>
<div className="flex justify-end gap-x-4 px-6 pt-4">
<div className="mr-[36px]">
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 pt-2 sm:pt-4">
<div className="w-full sm:w-auto sm:mr-[42px]">
<p className="ml-0 font-bold text-sm text-muted-foreground">
<Trans i18nKey="cart:subtotal" />
<Trans i18nKey="cart:order.subtotal" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-sm text-right">
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
@@ -94,14 +94,14 @@ export default function Cart({
</p>
</div>
</div>
<div className="flex justify-end gap-x-4 px-6 py-2">
<div className="mr-[36px]">
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 py-2 sm:py-4">
<div className="w-full sm:w-auto sm:mr-[42px]">
<p className="ml-0 font-bold text-sm text-muted-foreground">
<Trans i18nKey="cart:promotionsTotal" />
<Trans i18nKey="cart:order.promotionsTotal" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-sm text-right">
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
@@ -110,14 +110,14 @@ export default function Cart({
</p>
</div>
</div>
<div className="flex justify-end gap-x-4 px-6">
<div className="mr-[36px]">
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6">
<div className="w-full sm:w-auto sm:mr-[42px]">
<p className="ml-0 font-bold text-sm">
<Trans i18nKey="cart:total" />
<Trans i18nKey="cart:order.total" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-sm text-right">
{formatCurrency({
value: cart.total,
currencyCode: cart.currency_code,
@@ -129,7 +129,7 @@ export default function Cart({
</>
)}
<div className="flex sm:flex-row flex-col gap-y-6 py-8 gap-x-4">
<div className="flex sm:flex-row flex-col gap-y-6 py-4 sm:py-8 gap-x-4">
{IS_DISCOUNT_SHOWN && (
<Card
className="flex flex-col justify-between w-full sm:w-1/2"
@@ -139,7 +139,7 @@ export default function Cart({
<Trans i18nKey="cart:discountCode.title" />
</h5>
</CardHeader>
<CardContent>
<CardContent className="h-full">
<DiscountCode cart={{ ...cart }} />
</CardContent>
</Card>
@@ -154,7 +154,7 @@ export default function Cart({
<Trans i18nKey="cart:locations.title" />
</h5>
</CardHeader>
<CardContent>
<CardContent className="h-full">
<AnalysisLocation cart={{ ...cart }} synlabAnalyses={synlabAnalyses} />
</CardContent>
</Card>

View File

@@ -128,7 +128,7 @@ const ComparePackagesModal = async ({
return (
<TableRow key={id}>
<TableCell className="py-6">
<TableCell className="py-6 sm:max-w-[30vw]">
{title}{' '}
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
</TableCell>
@@ -136,10 +136,10 @@ const ComparePackagesModal = async ({
{isIncludedInStandard && <CheckWithBackground />}
</TableCell>
<TableCell align="center" className="py-6">
{(isIncludedInStandard || isIncludedInStandardPlus) && <CheckWithBackground />}
{isIncludedInStandardPlus && <CheckWithBackground />}
</TableCell>
<TableCell align="center" className="py-6">
{(isIncludedInStandard || isIncludedInStandardPlus || isIncludedInPremium) && <CheckWithBackground />}
{isIncludedInPremium && <CheckWithBackground />}
</TableCell>
</TableRow>
);

View File

@@ -8,7 +8,7 @@ import { Trans } from '@kit/ui/trans';
export default function DashboardCards() {
return (
<div className="flex gap-4 lg:px-4">
<div className="flex gap-4">
<Card
variant="gradient-success"
className="xs:w-1/2 sm:w-auto flex w-full flex-col justify-between"

View File

@@ -16,7 +16,6 @@ import {
} from 'lucide-react';
import { pathsConfig } from '@kit/shared/config';
import { getPersonParameters } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import {
Card,
@@ -30,7 +29,7 @@ import { cn } from '@kit/ui/utils';
import { isNil } from 'lodash';
import { BmiCategory } from '~/lib/types/bmi';
import {
import PersonalCode, {
bmiFromMetric,
getBmiBackgroundColor,
getBmiStatus,
@@ -60,7 +59,7 @@ const cards = ({
}) => [
{
title: 'dashboard:gender',
description: gender ?? 'dashboard:male',
description: gender ?? '-',
icon: <User />,
iconBg: 'bg-success',
},
@@ -84,7 +83,7 @@ const cards = ({
},
{
title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(),
description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus),
},
@@ -145,21 +144,26 @@ export default function Dashboard({
'id'
>[];
}) {
const params = getPersonParameters(account.personal_code!);
const bmiStatus = getBmiStatus(bmiThresholds, {
age: params?.age || 0,
height: account.accountParams?.height || 0,
weight: account.accountParams?.weight || 0,
});
const height = account.accountParams?.height || 0;
const weight = account.accountParams?.weight || 0;
let age: number = 0;
let gender: { label: string; value: string } | null = null;
try {
({ age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!));
} catch (e) {
console.error("Failed to parse personal code", e);
}
const bmiStatus = getBmiStatus(bmiThresholds, { age, height, weight });
return (
<>
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
{cards({
gender: params?.gender,
age: params?.age,
height: account.accountParams?.height,
weight: account.accountParams?.weight,
gender: gender?.label,
age,
height,
weight,
bmiStatus,
smoking: account.accountParams?.isSmoker,
}).map(

View File

@@ -21,7 +21,6 @@ import { formatCurrency } from '@/packages/shared/src/utils';
export type OrderAnalysisCard = Pick<
StoreProduct, 'title' | 'description' | 'subtitle'
> & {
isAvailable: boolean;
variant: { id: string };
price: number | null;
};
@@ -58,13 +57,12 @@ export default function OrderAnalysesCards({
}
return (
<div className="grid 2xs:grid-cols-3 gap-6 mt-4">
<div className="grid xs:grid-cols-3 gap-6 mt-4">
{analyses.map(({
title,
variant,
description,
subtitle,
isAvailable,
price,
}) => {
const formattedPrice = typeof price === 'number'
@@ -77,7 +75,7 @@ export default function OrderAnalysesCards({
return (
<Card
key={title}
variant={isAvailable ? "gradient-success" : "gradient-warning"}
variant="gradient-success"
className="flex flex-col justify-between"
>
<CardHeader className="flex-row">
@@ -86,46 +84,44 @@ export default function OrderAnalysesCards({
>
<HeartPulse className="size-4 fill-green-500" />
</div>
{isAvailable && (
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
<Button
size="icon"
variant="outline"
className="px-2 text-black"
onClick={() => handleSelect(variant.id)}
>
{variantAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
</Button>
</div>
)}
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
<Button
size="icon"
variant="outline"
className="px-2 text-black"
onClick={() => handleSelect(variant.id)}
>
{variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
</Button>
</div>
</CardHeader>
<CardFooter className="flex flex-col items-start gap-2">
<h5>
{title}
{description && (
<>
{' '}
<InfoTooltip
content={
<div className='flex flex-col gap-2'>
<span>{formattedPrice}</span>
<span>{description}</span>
</div>
}
/>
</>
<CardFooter className="flex gap-2">
<div className="flex flex-col items-start gap-2 flex-1">
<h5>
{title}
{description && (
<>
{' '}
<InfoTooltip
content={
<div className='flex flex-col gap-2'>
<span>{formattedPrice}</span>
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>
{subtitle && (
<CardDescription>
{subtitle}
</CardDescription>
)}
</h5>
{isAvailable && subtitle && (
<CardDescription>
{subtitle}
</CardDescription>
)}
{!isAvailable && (
<CardDescription>
<Trans i18nKey={'order-analysis:analysisNotAvailable'} />
</CardDescription>
)}
</div>
<div className="flex flex-col items-end gap-2 self-end text-sm">
<span>{formattedPrice}</span>
</div>
</CardFooter>
</Card>
);

View File

@@ -24,7 +24,7 @@ export default function CartTotals({ medusaOrder }: {
<div className="flex flex-col gap-y-2 txt-medium text-ui-fg-subtle ">
<div className="flex items-center justify-between">
<span className="flex gap-x-1 items-center">
<Trans i18nKey="cart:orderConfirmed.subtotal" />
<Trans i18nKey="cart:order.subtotal" />
</span>
<span data-testid="cart-subtotal" data-value={subtotal || 0}>
{formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })}
@@ -32,7 +32,7 @@ export default function CartTotals({ medusaOrder }: {
</div>
{!!discount_total && (
<div className="flex items-center justify-between">
<span><Trans i18nKey="cart:orderConfirmed.discount" /></span>
<span><Trans i18nKey="cart:order.promotionsTotal" /></span>
<span
className="text-ui-fg-interactive"
data-testid="cart-discount"
@@ -43,17 +43,17 @@ export default function CartTotals({ medusaOrder }: {
</span>
</div>
)}
<div className="flex justify-between">
{/* <div className="flex justify-between">
<span className="flex gap-x-1 items-center ">
<Trans i18nKey="cart:orderConfirmed.taxes" />
</span>
<span data-testid="cart-taxes" data-value={tax_total || 0}>
{formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })}
</span>
</div>
</div> */}
{!!gift_card_total && (
<div className="flex items-center justify-between">
<span><Trans i18nKey="cart:orderConfirmed.giftCard" /></span>
<span><Trans i18nKey="cart:order.giftCard" /></span>
<span
className="text-ui-fg-interactive"
data-testid="cart-gift-card-amount"
@@ -67,7 +67,7 @@ export default function CartTotals({ medusaOrder }: {
</div>
<div className="h-px w-full border-b border-gray-200 my-4" />
<div className="flex items-center justify-between text-ui-fg-base mb-2 txt-medium ">
<span className="font-bold"><Trans i18nKey="cart:orderConfirmed.total" /></span>
<span className="font-bold"><Trans i18nKey="cart:order.total" /></span>
<span
className="txt-xlarge-plus"
data-testid="cart-total"

View File

@@ -7,15 +7,23 @@ export default function OrderDetails({ order }: {
}) {
return (
<div className="flex flex-col gap-y-2">
<span>
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{" "}
<div>
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.orderNumber" />:{" "}
</span>
<span>
{order.medusa_order_id}
</span>
</div>
<div>
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{" "}
</span>
<span>
{formatDate(order.created_at, 'dd.MM.yyyy HH:mm')}
</span>
</span>
<span className="text-ui-fg-interactive">
<Trans i18nKey="cart:orderConfirmed.orderNumber" />: <span data-testid="order-id">{order.medusa_order_id}</span>
</span>
</div>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { createPageViewLog, PageViewAction } from "~/lib/services/audit/pageView
import { loadCurrentUserAccount } from "../../_lib/server/load-user-account";
export async function logAnalysisResultsNavigateAction(analysisOrderId: string) {
const account = await loadCurrentUserAccount();
const { account } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}

View File

@@ -41,7 +41,7 @@ async function analysesLoader() {
const categoryProducts = category
? await listProducts({
countryCode,
queryParams: { limit: 100, category_id: category.id },
queryParams: { limit: 100, category_id: category.id, order: 'title' },
})
: null;
@@ -51,8 +51,10 @@ async function analysesLoader() {
return {
analyses:
categoryProducts?.response.products.map<OrderAnalysisCard>(
({ title, description, subtitle, variants, status, metadata }) => {
categoryProducts?.response.products
.filter(({ status, metadata }) => status === 'published' && !!metadata?.analysisIdOriginal)
.map<OrderAnalysisCard>(
({ title, description, subtitle, variants }) => {
const variant = variants![0]!;
return {
title,
@@ -61,8 +63,6 @@ async function analysesLoader() {
variant: {
id: variant.id,
},
isAvailable:
status === 'published' && !!metadata?.analysisIdOriginal,
price: variant.calculated_price?.calculated_amount ?? null,
};
},

View File

@@ -1,5 +1,4 @@
import { cache } from 'react';
import Isikukood, { Gender } from 'isikukood';
import { listProductTypes, listProducts } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
@@ -8,6 +7,7 @@ import type { StoreProduct } from '@medusajs/types';
import { loadCurrentUserAccount } from './load-user-account';
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
import PersonalCode from '~/lib/utils';
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
@@ -32,27 +32,8 @@ function userSpecificVariantLoader({
if (!personalCode) {
throw new Error('Personal code not found');
}
const parsed = new Isikukood(personalCode);
const ageRange = (() => {
const age = parsed.getAge();
if (age >= 18 && age <= 29) {
return '18-29';
}
if (age >= 30 && age <= 39) {
return '30-39';
}
if (age >= 40 && age <= 49) {
return '40-49';
}
if (age >= 50 && age <= 59) {
return '50-59';
}
if (age >= 60) {
return '60';
}
throw new Error('Age range not supported');
})();
const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F';
const { ageRange, gender: { value: gender } } = PersonalCode.parsePersonalCode(personalCode);
return ({
product,
@@ -89,6 +70,7 @@ async function analysisPackageElementsLoader({
queryParams: {
id: analysisElementMedusaProductIds,
limit: 100,
order: "title",
},
});
@@ -140,8 +122,9 @@ async function analysisPackagesWithVariantLoader({
return [
...acc,
{
variant,
variantId: variant.id,
nrOfAnalyses: getAnalysisElementMedusaProductIds([product]).length,
nrOfAnalyses: getAnalysisElementMedusaProductIds([{ ...product, variant }]).length,
price: variant.calculated_price?.calculated_amount ?? 0,
title: product.title,
subtitle: product.subtitle,
@@ -158,7 +141,7 @@ async function analysisPackagesWithVariantLoader({
}
async function analysisPackagesLoader() {
const account = await loadCurrentUserAccount();
const { account } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}

View File

@@ -16,14 +16,17 @@ export const loadUserAccount = cache(accountLoader);
export async function loadCurrentUserAccount() {
const user = await requireUserInServerComponent();
return user?.identities?.[0]?.id
? await loadUserAccount(user?.identities?.[0]?.id)
: null;
const userId = user?.id;
if (!userId) {
return { account: null, user: null };
}
const account = await loadUserAccount(userId);
return { account, user };
}
async function accountLoader(accountId: string) {
async function accountLoader(userId: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
return api.getAccount(accountId);
return api.getPersonalAccountByUserId(userId);
}

View File

@@ -28,20 +28,15 @@ async function workspaceLoader() {
const workspacePromise = api.getAccountWorkspace();
// TODO!: remove before deploy to prod
const tempAccountsPromise = () => api.loadTempUserAccounts();
const [accounts, workspace, user, tempVisibleAccounts] = await Promise.all([
const [accounts, workspace, user] = await Promise.all([
accountsPromise(),
workspacePromise,
requireUserInServerComponent(),
tempAccountsPromise(),
]);
return {
accounts,
workspace,
user,
tempVisibleAccounts,
};
}

View File

@@ -7,7 +7,6 @@ import { Trans } from 'react-i18next';
import { AccountWithParams } from '@kit/accounts/api';
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
import { Button } from '@kit/ui/button';
import { Card, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
@@ -25,7 +24,6 @@ import {
SelectValue,
} from '@kit/ui/select';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
import {
AccountSettings,
@@ -131,7 +129,11 @@ export default function AccountSettingsForm({
</FormLabel>
<FormControl>
<Input {...field} />
<Input
placeholder="cm"
type="number"
{...field}
/>
</FormControl>
<FormMessage />
@@ -150,7 +152,11 @@ export default function AccountSettingsForm({
</FormLabel>
<FormControl>
<Input {...field} />
<Input
placeholder="kg"
type="number"
{...field}
/>
</FormControl>
<FormMessage />

View File

@@ -12,8 +12,8 @@ export const accountSettingsSchema = z.object({
email: z.email({ error: 'error:invalidEmail' }).nullable(),
phone: z.e164({ error: 'error:invalidPhone' }),
accountParams: z.object({
height: z.coerce.number({ error: 'error:invalidNumber' }),
weight: z.coerce.number({ error: 'error:invalidNumber' }),
height: z.coerce.number({ error: 'error:invalidNumber' }).gt(0),
weight: z.coerce.number({ error: 'error:invalidNumber' }).gt(0),
isSmoker: z.boolean().optional().nullable(),
}),
});

View File

@@ -17,7 +17,7 @@ export const generateMetadata = async () => {
};
async function PersonalAccountSettingsPage() {
const account = await loadCurrentUserAccount();
const { account } = await loadCurrentUserAccount();
return (
<PageBody>
<div className="mx-auto w-full bg-white p-6">

View File

@@ -1,13 +1,9 @@
import { CardTitle } from '@kit/ui/card';
import { LanguageSelector } from '@kit/ui/language-selector';
import { Trans } from '@kit/ui/trans';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
import AccountPreferencesForm from '../_components/account-preferences-form';
import SettingsSectionHeader from '../_components/settings-section-header';
export default async function PreferencesPage() {
const account = await loadCurrentUserAccount();
const { account } = await loadCurrentUserAccount();
return (
<div className="mx-auto w-full bg-white p-6">
@@ -16,7 +12,6 @@ export default async function PreferencesPage() {
titleKey="account:preferencesTabLabel"
descriptionKey="account:preferencesTabDescription"
/>
<AccountPreferencesForm account={account} />
</div>
</div>

View File

@@ -31,11 +31,11 @@ export const getAccountHealthDetailsFields = (
>[],
members: Database['medreport']['Functions']['get_account_members']['Returns'],
): AccountHealthDetailsField[] => {
const avarageWeight =
const averageWeight =
memberParams.reduce((sum, r) => sum + r.weight!, 0) / memberParams.length;
const avarageHeight =
const averageHeight =
memberParams.reduce((sum, r) => sum + r.height!, 0) / memberParams.length;
const avarageAge =
const averageAge =
members.reduce((sum, r) => {
const person = new Isikukood(r.personal_code);
return sum + person.getAge();
@@ -48,11 +48,11 @@ export const getAccountHealthDetailsFields = (
const person = new Isikukood(r.personal_code);
return person.getGender() === 'female';
}).length;
const averageBMI = bmiFromMetric(avarageWeight, avarageHeight);
const averageBMI = bmiFromMetric(averageWeight, averageHeight);
const bmiStatus = getBmiStatus(bmiThresholds, {
age: avarageAge,
height: avarageHeight,
weight: avarageWeight,
age: averageAge,
height: averageHeight,
weight: averageWeight,
});
const malePercentage = members.length
? (numberOfMaleMembers / members.length) * 100
@@ -76,7 +76,7 @@ export const getAccountHealthDetailsFields = (
},
{
title: 'teams:healthDetails.avgAge',
value: avarageAge.toFixed(0),
value: averageAge.toFixed(0),
Icon: Clock,
iconBg: 'bg-success',
},

View File

@@ -1,4 +1,3 @@
import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component';
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
@@ -12,8 +11,7 @@ export default async function HomeLayout({
}) {
const client = getSupabaseServerClient();
const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount();
const { account, user } = await loadCurrentUserAccount();
const api = createAccountsApi(client);
const hasAccountTeamMembership = await api.hasAccountTeamMembership(

View File

@@ -1,4 +1,5 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Scale } from 'lucide-react';
@@ -27,6 +28,10 @@ async function SelectPackagePage() {
const { analysisPackageElements, analysisPackages, countryCode } =
await loadAnalysisPackages();
if (analysisPackageElements.length === 0) {
return redirect(pathsConfig.app.home);
}
return (
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
<MedReportLogo />