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

This commit is contained in:
Helena
2025-09-08 09:36:46 +03:00
106 changed files with 4793 additions and 4083 deletions

View File

@@ -9,18 +9,16 @@ import { ContactEmailSchema } from '../contact-email.schema';
const contactEmail = z
.string({
description: `The email where you want to receive the contact form submissions.`,
required_error:
error:
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
})
}).describe(`The email where you want to receive the contact form submissions.`)
.parse(process.env.CONTACT_EMAIL);
const emailFrom = z
.string({
description: `The email sending address.`,
required_error:
error:
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
})
}).describe(`The email sending address.`)
.parse(process.env.EMAIL_SENDER);
export const sendContactEmail = enhanceAction(

View File

@@ -164,10 +164,15 @@ export default async function syncAnalysisGroups() {
console.info('Inserting sync entry');
await createSyncSuccessEntry();
} catch (e) {
await createSyncFailEntry(JSON.stringify(e));
console.error(e);
const errorMessage = e instanceof Error ? e.message : String(e);
await createSyncFailEntry(JSON.stringify({
message: errorMessage,
stack: e instanceof Error ? e.stack : undefined,
name: e instanceof Error ? e.name : 'Unknown',
}, null, 2));
console.error('Sync failed:', e);
throw new Error(
`Failed to sync public message data, error: ${JSON.stringify(e)}`,
`Failed to sync public message data, error: ${errorMessage}`,
);
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
import { getOrderedAnalysisIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
import { retrieveOrder } from "@lib/data/orders";
import { getMedipostDispatchTries } from "~/lib/services/audit.service";
@@ -25,7 +25,7 @@ export const POST = async (request: NextRequest) => {
try {
const medusaOrder = await retrieveOrder(medusaOrderId);
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
console.info("Successfully sent order to medipost");
return NextResponse.json({

View File

@@ -3,7 +3,7 @@ import { getAnalysisOrdersAdmin } 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";
import { getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
import { getOrderedAnalysisIds } from "~/lib/services/medipost.service";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
@@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
const medusaOrder = await retrieveOrder(medusaOrderId)
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
const idsToSend = orderedAnalysisElementsIds;
@@ -35,8 +35,8 @@ export async function POST(request: NextRequest) {
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [],
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
});

View File

@@ -3,7 +3,7 @@ import { getOrder } 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";
import { createMedipostActionLog, getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
import { createMedipostActionLog, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
export async function POST(request: Request) {
// const isDev = process.env.NODE_ENV === 'development';
@@ -11,16 +11,15 @@ export async function POST(request: Request) {
// return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 });
// }
const { medusaOrderId, maxItems = null } = await request.json();
const { medusaOrderId } = await request.json();
const medusaOrder = await retrieveOrder(medusaOrderId)
const medreportOrder = await getOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} (${maxItems ?? 'all'}) ordered analysis elements`);
const idsToSend = typeof maxItems === 'number' ? orderedAnalysisElementsIds.slice(0, maxItems) : orderedAnalysisElementsIds;
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
const messageXml = await composeOrderTestResponseXML({
person: {
idCode: account.personal_code!,
@@ -28,8 +27,8 @@ export async function POST(request: Request) {
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [],
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),
});

View File

@@ -3,17 +3,17 @@ import { z } from 'zod';
export const UpdateAccountSchema = z.object({
firstName: z
.string({
required_error: 'First name is required',
error: 'First name is required',
})
.nonempty(),
lastName: z
.string({
required_error: 'Last name is required',
error: 'Last name is required',
})
.nonempty(),
personalCode: z
.string({
required_error: 'Personal code is required',
error: 'Personal code is required',
})
.nonempty(),
email: z.string().email({
@@ -21,21 +21,25 @@ export const UpdateAccountSchema = z.object({
}),
phone: z
.string({
required_error: 'Phone number is required',
error: 'Phone number is required',
})
.nonempty(),
city: z.string().optional(),
weight: z
.number({
required_error: 'Weight is required',
invalid_type_error: 'Weight must be a number',
error: (issue) =>
issue.input === undefined
? 'Weight is required'
: 'Weight must be a number',
})
.gt(0, { message: 'Weight must be greater than 0' }),
height: z
.number({
required_error: 'Height is required',
invalid_type_error: 'Height must be a number',
error: (issue) =>
issue.input === undefined
? 'Height is required'
: 'Height must be a number',
})
.gt(0, { message: 'Height must be greater than 0' }),
userConsent: z.boolean().refine((val) => val === true, {

View File

@@ -8,7 +8,7 @@ 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 { getOrderedAnalysisElementsIds, sendOrderToMedipost } from '~/lib/services/medipost.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';
@@ -20,12 +20,12 @@ const env = () => z
.object({
emailSender: z
.string({
required_error: 'EMAIL_SENDER is required',
error: 'EMAIL_SENDER is required',
})
.min(1),
siteUrl: z
.string({
required_error: 'NEXT_PUBLIC_SITE_URL is required',
error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
})
@@ -114,7 +114,7 @@ export async function processMontonioCallback(orderToken: string) {
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
const { productTypes } = await listProductTypes();

View File

@@ -1,17 +1,11 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { retrieveCart } from '@lib/data/cart';
import { StoreCart } from '@medusajs/types';
import { z } from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { AppLogo } from '@kit/shared/components/app-logo';
import {
pathsConfig,
personalAccountNavigationConfig,
} from '@kit/shared/config';
import { pathsConfig } from '@kit/shared/config';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
@@ -24,40 +18,11 @@ import { HomeSidebar } from '../_components/home-sidebar';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState());
if (state.style === 'sidebar') {
return <SidebarLayout>{children}</SidebarLayout>;
}
return <HeaderLayout>{children}</HeaderLayout>;
}
export default withI18n(UserHomeLayout);
function SidebarLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const state = use(getLayoutState());
return (
<UserWorkspaceContextProvider value={workspace}>
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<HomeSidebar />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} cart={null} />
</PageMobileNavigation>
{children}
</Page>
</SidebarProvider>
</UserWorkspaceContextProvider>
);
}
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart());
@@ -101,27 +66,3 @@ function MobileNavigation({
</>
);
}
async function getLayoutState() {
const cookieStore = await cookies();
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
const layoutStyleCookie = cookieStore.get('layout-style');
const sidebarOpenCookie = cookieStore.get('sidebar:state');
const sidebarOpen = sidebarOpenCookie
? sidebarOpenCookie.value === 'false'
: !personalAccountNavigationConfig.sidebarCollapsed;
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
const style = parsedStyle.success
? parsedStyle.data
: personalAccountNavigationConfig.style;
return {
open: sidebarOpen,
style,
};
}

View File

@@ -1,6 +1,7 @@
"use client"
import { Badge, Heading, Text } from "@medusajs/ui"
import { Badge, Text } from "@medusajs/ui"
import { toast } from '@kit/ui/sonner';
import React, { useActionState } from "react";
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
@@ -31,11 +32,19 @@ export default function DiscountCode({ cart }: {
const removePromotionCode = async (code: string) => {
const validPromotions = promotions.filter(
(promotion) => promotion.code !== code
(promotion) => promotion.code !== code,
)
await applyPromotions(
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!)
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!),
{
onSuccess: () => {
toast.success(t('cart:discountCode.removeSuccess'));
},
onError: () => {
toast.error(t('cart:discountCode.removeError'));
},
}
)
}
@@ -45,7 +54,14 @@ export default function DiscountCode({ cart }: {
.map((p) => p.code!)
codes.push(code.toString())
await applyPromotions(codes)
await applyPromotions(codes, {
onSuccess: () => {
toast.success(t('cart:discountCode.addSuccess'));
},
onError: () => {
toast.error(t('cart:discountCode.addError'));
},
});
form.reset()
}
@@ -64,7 +80,7 @@ export default function DiscountCode({ cart }: {
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
className="w-full mb-2 flex gap-x-2"
className="w-full mb-2 flex gap-x-2 sm:flex-row flex-col gap-y-2"
>
<FormField
name={'code'}
@@ -87,16 +103,12 @@ export default function DiscountCode({ cart }: {
</form>
</Form>
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:discountCode.subtitle'} />
</p>
{promotions.length > 0 && (
<div className="w-full flex items-center">
<div className="flex flex-col w-full">
<Heading className="txt-medium mb-2">
Promotion(s) applied:
</Heading>
{promotions.length > 0 ? (
<div className="w-full flex items-center mt-4">
<div className="flex flex-col w-full gap-y-2">
<p>
<Trans i18nKey={'cart:discountCode.appliedCodes'} />
</p>
{promotions.map((promotion) => {
return (
@@ -110,6 +122,7 @@ export default function DiscountCode({ cart }: {
<Badge
color={promotion.is_automatic ? "green" : "grey"}
size="small"
className="px-4"
>
{promotion.code}
</Badge>{" "}
@@ -151,7 +164,7 @@ export default function DiscountCode({ cart }: {
>
<Trash size={14} />
<span className="sr-only">
Remove discount code from order
<Trans i18nKey={'cart:discountCode.remove'} />
</span>
</button>
)}
@@ -160,6 +173,10 @@ export default function DiscountCode({ cart }: {
})}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:discountCode.subtitle'} />
</p>
)}
</div>
)

View File

@@ -18,7 +18,7 @@ import { useTranslation } from "react-i18next";
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
import AnalysisLocation from "./analysis-location";
const IS_DISCOUNT_SHOWN = false as boolean;
const IS_DISCOUNT_SHOWN = true as boolean;
export default function Cart({
cart,
@@ -69,7 +69,7 @@ export default function Cart({
const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0;
const isLocationsShown = synlabAnalyses.length > 0;
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
<div className="flex flex-col bg-white gap-y-6">
@@ -77,28 +77,62 @@ export default function Cart({
<CartItems cart={cart} items={ttoServiceItems} productColumnLabelKey="cart:items.ttoServices.productColumnLabel" />
</div>
{hasCartItems && (
<div className="flex justify-end gap-x-4 px-6 py-4">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm">
<Trans i18nKey="cart:total" />
</p>
<>
<div className="flex justify-end gap-x-4 px-6 pt-4">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm text-muted-foreground">
<Trans i18nKey="cart:subtotal" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
<div className="flex justify-end gap-x-4 px-6 py-2">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm text-muted-foreground">
<Trans i18nKey="cart:promotionsTotal" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-x-4 px-6">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm">
<Trans i18nKey="cart:total" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
</>
)}
<div className="flex gap-y-6 py-8">
<div className="flex sm:flex-row flex-col gap-y-6 py-8 gap-x-4">
{IS_DISCOUNT_SHOWN && (
<Card
className="flex flex-col justify-between w-1/2"
className="flex flex-col justify-between w-full sm:w-1/2"
>
<CardHeader className="pb-4">
<h5>
@@ -113,7 +147,7 @@ export default function Cart({
{isLocationsShown && (
<Card
className="flex flex-col justify-between w-1/2"
className="flex flex-col justify-between w-full sm:w-1/2"
>
<CardHeader className="pb-4">
<h5>

View File

@@ -0,0 +1,128 @@
'use client';
import Link from 'next/link';
import { BlendingModeIcon } from '@radix-ui/react-icons';
import {
Droplets,
} from 'lucide-react';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardHeader,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
const dummyRecommendations = [
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-cyan/10 text-cyan',
title: 'Kolesterooli kontroll',
description: 'HDL-kolestrool',
tooltipContent: 'Selgitus',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-primary/10 text-primary',
title: 'Kolesterooli kontroll',
tooltipContent: 'Selgitus',
description: 'LDL-Kolesterool',
buttonText: 'Broneeri',
href: '/home/booking',
},
{
icon: <Droplets />,
color: 'bg-destructive/10 text-destructive',
title: 'Vererõhu kontroll',
tooltipContent: 'Selgitus',
description: 'Score-Risk 2',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
];
export default function DashboardRecommendations() {
return (
<Card>
<CardHeader className="items-start">
<h4>
<Trans i18nKey="dashboard:recommendedForYou" />
</h4>
</CardHeader>
<CardContent className="space-y-6">
{dummyRecommendations.map(
(
{
icon,
color,
title,
description,
tooltipContent,
price,
buttonText,
href,
},
index,
) => {
return (
<div
className="flex w-full justify-between gap-3 overflow-scroll"
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
color,
)}
>
{icon}
</div>
<div className="min-w-fit">
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title}
<InfoTooltip content={tooltipContent} />
</div>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
</div>
<div className="grid w-36 min-w-fit auto-rows-fr grid-cols-2 items-center gap-4">
<p className="text-sm font-medium"> {price}</p>
{href ? (
<Link href={href}>
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
</Link>
) : (
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
)}
</div>
</div>
);
},
)}
</CardContent>
</Card>
);
}

View File

@@ -7,33 +7,41 @@ import { Database } from '@/packages/supabase/src/database.types';
import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons';
import {
Activity,
ChevronRight,
Clock9,
Droplets,
Pill,
Scale,
TrendingUp,
User,
} from 'lucide-react';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { pathsConfig } from '@kit/shared/config';
import { getPersonParameters } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardProps,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { isNil } from 'lodash';
import { BmiCategory } from '~/lib/types/bmi';
import {
bmiFromMetric,
getBmiBackgroundColor,
getBmiStatus,
} from '~/lib/utils';
import DashboardRecommendations from './dashboard-recommendations';
const getCardVariant = (isSuccess: boolean | null): CardProps['variant'] => {
if (isSuccess === null) return 'default';
if (isSuccess) return 'gradient-success';
return 'gradient-destructive';
};
const cards = ({
gender,
@@ -41,111 +49,91 @@ const cards = ({
height,
weight,
bmiStatus,
smoking,
}: {
gender?: string;
age?: number;
height?: number | null;
weight?: number | null;
bmiStatus: BmiCategory | null;
smoking?: boolean | null;
}) => [
{
title: 'dashboard:gender',
description: gender ?? 'dashboard:male',
icon: <User />,
iconBg: 'bg-success',
},
{
title: 'dashboard:age',
description: age ? `${age}` : '-',
icon: <Clock9 />,
iconBg: 'bg-success',
},
{
title: 'dashboard:height',
description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success',
},
{
title: 'dashboard:weight',
description: weight ? `${weight}kg` : '-',
icon: <Scale />,
iconBg: 'bg-success',
},
{
title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(),
icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus),
},
{
title: 'dashboard:bloodPressure',
description: '-',
icon: <Activity />,
iconBg: 'bg-warning',
},
{
title: 'dashboard:cholesterol',
description: '-',
icon: <BlendingModeIcon className="size-4" />,
iconBg: 'bg-destructive',
},
{
title: 'dashboard:ldlCholesterol',
description: '-',
icon: <Pill />,
iconBg: 'bg-warning',
},
// {
// title: 'Score 2',
// description: 'Normis',
// icon: <LineChart />,
// iconBg: 'bg-success',
// },
// {
// title: 'dashboard:smoking',
// description: 'dashboard:respondToQuestion',
// descriptionColor: 'text-primary',
// icon: (
// <Button size="icon" variant="outline" className="px-2 text-black">
// <ChevronRight className="size-4 stroke-2" />
// </Button>
// ),
// cardVariant: 'gradient-success' as CardProps['variant'],
// },
];
{
title: 'dashboard:gender',
description: gender ?? 'dashboard:male',
icon: <User />,
iconBg: 'bg-success',
},
{
title: 'dashboard:age',
description: age ? `${age}` : '-',
icon: <Clock9 />,
iconBg: 'bg-success',
},
{
title: 'dashboard:height',
description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success',
},
{
title: 'dashboard:weight',
description: weight ? `${weight}kg` : '-',
icon: <Scale />,
iconBg: 'bg-success',
},
{
title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(),
icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus),
},
{
title: 'dashboard:bloodPressure',
description: '-',
icon: <Activity />,
iconBg: 'bg-warning',
},
{
title: 'dashboard:cholesterol',
description: '-',
icon: <BlendingModeIcon className="size-4" />,
iconBg: 'bg-destructive',
},
{
title: 'dashboard:ldlCholesterol',
description: '-',
icon: <Pill />,
iconBg: 'bg-warning',
},
// {
// title: 'Score 2',
// description: 'Normis',
// icon: <LineChart />,
// iconBg: 'bg-success',
// },
{
title: 'dashboard:smoking',
description:
isNil(smoking)
? 'dashboard:respondToQuestion'
: !!smoking
? 'common:yes'
: 'common:no',
descriptionColor: 'text-primary',
icon:
isNil(smoking) ? (
<Link href={pathsConfig.app.personalAccountSettings}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
) : null,
cardVariant: getCardVariant(isNil(smoking) ? null : !smoking),
},
];
const dummyRecommendations = [
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-cyan/10 text-cyan',
title: 'Kolesterooli kontroll',
description: 'HDL-kolestrool',
tooltipContent: 'Selgitus',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-primary/10 text-primary',
title: 'Kolesterooli kontroll',
tooltipContent: 'Selgitus',
description: 'LDL-Kolesterool',
buttonText: 'Broneeri',
href: '/home/booking',
},
{
icon: <Droplets />,
color: 'bg-destructive/10 text-destructive',
title: 'Vererõhu kontroll',
tooltipContent: 'Selgitus',
description: 'Score-Risk 2',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
];
const IS_SHOWN_RECOMMENDATIONS = false as boolean;
export default function Dashboard({
account,
@@ -160,8 +148,8 @@ export default function Dashboard({
const params = getPersonParameters(account.personal_code!);
const bmiStatus = getBmiStatus(bmiThresholds, {
age: params?.age || 0,
height: account.account_params?.[0]?.height || 0,
weight: account.account_params?.[0]?.weight || 0,
height: account.accountParams?.height || 0,
weight: account.accountParams?.weight || 0,
});
return (
@@ -170,21 +158,22 @@ export default function Dashboard({
{cards({
gender: params?.gender,
age: params?.age,
height: account.account_params?.[0]?.height,
weight: account.account_params?.[0]?.weight,
height: account.accountParams?.height,
weight: account.accountParams?.weight,
bmiStatus,
smoking: account.accountParams?.isSmoker,
}).map(
({
title,
description,
icon,
iconBg,
// cardVariant,
cardVariant,
// descriptionColor,
}) => (
<Card
key={title}
// variant={cardVariant}
variant={cardVariant}
className="flex flex-col justify-between"
>
<CardHeader className="items-end-safe">
@@ -211,79 +200,7 @@ export default function Dashboard({
),
)}
</div>
<Card>
<CardHeader className="items-start">
<h4>
<Trans i18nKey="dashboard:recommendedForYou" />
</h4>
</CardHeader>
<CardContent className="space-y-6">
{dummyRecommendations.map(
(
{
icon,
color,
title,
description,
tooltipContent,
price,
buttonText,
href,
},
index,
) => {
return (
<div
className="flex w-full justify-between gap-3 overflow-scroll"
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
color,
)}
>
{icon}
</div>
<div className="min-w-fit">
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title}
<InfoTooltip content={tooltipContent} />
</div>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
</div>
<div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4 min-w-fit">
<p className="text-sm font-medium"> {price}</p>
{href ? (
<Link href={href}>
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
</Link>
) : (
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
)}
</div>
</div>
);
},
)}
</CardContent>
</Card>
{IS_SHOWN_RECOMMENDATIONS && <DashboardRecommendations />}
</>
);
}

View File

@@ -31,7 +31,8 @@ export async function HomeMenuNavigation(props: {
})
: 0;
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0;
return (
@@ -39,15 +40,18 @@ export async function HomeMenuNavigation(props: {
<div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}>
<AppLogo href={pathsConfig.app.home} />
</div>
<Search
{/* TODO: add search functionality */}
{/* <Search
className="flex grow"
startElement={<Trans i18nKey="common:search" values={{ end: '...' }} />}
/>
/> */}
<div className="flex items-center justify-end gap-3">
{/* TODO: add wallet functionality
<Card className="px-6 py-2">
<span>€ {Number(0).toFixed(2).replace('.', ',')}</span>
</Card>
*/}
{hasCartItems && (
<Button
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
@@ -56,7 +60,7 @@ export async function HomeMenuNavigation(props: {
<span className="flex items-center text-nowrap">{totalValue}</span>
</Button>
)}
<Link href="/home/cart">
<Link href={pathsConfig.app.cart}>
<Button
variant="ghost"
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"

View File

@@ -4,11 +4,13 @@ import { useMemo } from 'react';
import Link from 'next/link';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import { StoreCart } from '@medusajs/types';
import { Cross, LogOut, Menu, Shield, ShoppingCart } from 'lucide-react';
import { Cross, Menu, Shield, ShoppingCart } from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import {
pathsConfig,
personalAccountNavigationConfig,
@@ -91,7 +93,7 @@ export function HomeMobileNavigation(props: {
<If condition={props.cart && hasCartItems}>
<DropdownMenuGroup>
<DropdownLink
path="/home/cart"
path={pathsConfig.app.cart}
label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartQuantityTotal }}
@@ -145,49 +147,4 @@ export function HomeMobileNavigation(props: {
);
}
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
labelOptions?: Record<string, any>;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem asChild key={props.path}>
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans
i18nKey={props.label}
defaults={props.label}
values={props.labelOptions}
/>
</span>
</Link>
</DropdownMenuItem>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
@@ -15,12 +16,14 @@ import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { Trans } from '@kit/ui/trans';
import { toast } from '@kit/ui/sonner';
import { formatCurrency } from '@/packages/shared/src/utils';
export type OrderAnalysisCard = Pick<
StoreProduct, 'title' | 'description' | 'subtitle'
> & {
isAvailable: boolean;
variant: { id: string };
price: number | null;
};
export default function OrderAnalysesCards({
@@ -30,23 +33,26 @@ export default function OrderAnalysesCards({
analyses: OrderAnalysisCard[];
countryCode: string;
}) {
const [isAddingToCart, setIsAddingToCart] = useState(false);
const { i18n: { language } } = useTranslation()
const [variantAddingToCart, setVariantAddingToCart] = useState<string | null>(null);
const handleSelect = async (variantId: string) => {
if (isAddingToCart) {
if (variantAddingToCart) {
return null;
}
setIsAddingToCart(true);
setVariantAddingToCart(variantId);
try {
await handleAddToCart({
selectedVariant: { id: variantId },
countryCode,
});
toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
setIsAddingToCart(false);
setVariantAddingToCart(null);
} catch (e) {
toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
setIsAddingToCart(false);
setVariantAddingToCart(null);
console.error(e);
}
}
@@ -59,7 +65,15 @@ export default function OrderAnalysesCards({
description,
subtitle,
isAvailable,
price,
}) => {
const formattedPrice = typeof price === 'number'
? formatCurrency({
currencyCode: 'eur',
locale: language,
value: price,
})
: null;
return (
<Card
key={title}
@@ -80,7 +94,7 @@ export default function OrderAnalysesCards({
className="px-2 text-black"
onClick={() => handleSelect(variant.id)}
>
{isAddingToCart ? <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>
</div>
)}
@@ -91,7 +105,14 @@ export default function OrderAnalysesCards({
{description && (
<>
{' '}
<InfoTooltip content={`${description}`} />
<InfoTooltip
content={
<div className='flex flex-col gap-2'>
<span>{formattedPrice}</span>
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>

View File

@@ -1,6 +1,5 @@
import { cache } from 'react';
import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';

View File

@@ -1,11 +1,10 @@
import { cache } from 'react';
import { getProductCategories } from '@lib/data/categories';
import { listProductTypes } from '@lib/data/products';
import { listProducts, listProductTypes } from '@lib/data/products';
import { listRegions } from '@lib/data/regions';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
import { ServiceCategory } from '../../_components/service-categories';
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
@@ -39,13 +38,20 @@ async function analysesLoader() {
const category = productCategories.find(
({ metadata }) => metadata?.page === 'order-analysis',
);
const categoryProducts = category
? await listProducts({
countryCode,
queryParams: { limit: 100, category_id: category.id },
})
: null;
const serviceCategories = productCategories.filter(
({ parent_category }) => parent_category?.handle === 'tto-categories',
);
return {
analyses:
category?.products?.map<OrderAnalysisCard>(
categoryProducts?.response.products.map<OrderAnalysisCard>(
({ title, description, subtitle, variants, status, metadata }) => {
const variant = variants![0]!;
return {
@@ -57,6 +63,7 @@ async function analysesLoader() {
},
isAvailable:
status === 'published' && !!metadata?.analysisIdOriginal,
price: variant.calculated_price?.calculated_amount ?? null,
};
},
) ?? [],

View File

@@ -0,0 +1,111 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
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, CardDescription, CardTitle } from '@kit/ui/card';
import { Form } from '@kit/ui/form';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
import {
AccountPreferences,
accountPreferencesSchema,
} from '../_lib/account-preferences.schema';
import { updatePersonalAccountPreferencesAction } from '../_lib/server/actions';
import { LanguageSelector } from '@kit/ui/language-selector';
export default function AccountPreferencesForm({
account,
}: {
account: AccountWithParams | null;
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const form = useForm({
resolver: zodResolver(accountPreferencesSchema),
defaultValues: {
preferredLanguage: account?.preferred_locale,
isConsentToAnonymizedCompanyStatistics:
!!account?.has_consent_anonymized_company_statistics,
},
});
const { register, handleSubmit, watch, setValue } = form;
const onSubmit = async (data: AccountPreferences) => {
if (!account?.id) {
return toast.error(<Trans i18nKey="account:updateAccountError" />);
}
const result = await updatePersonalAccountPreferencesAction({
accountId: account.id,
data,
});
if (result.success) {
revalidateUserDataQuery(account.primary_owner_user_id);
return toast.success(
<Trans i18nKey="account:updateAccountPreferencesSuccess" />,
);
}
return toast.error(
<Trans i18nKey="account:updateAccountPreferencesError" />,
);
};
const watchedConsent = watch('isConsentToAnonymizedCompanyStatistics');
if (!account) return null;
return (
<>
<div className="space-y-2">
<CardTitle className="text-base">
<Trans i18nKey={'account:language'} />
</CardTitle>
<LanguageSelector />
</div>
<Form {...form}>
<form
className="flex flex-col gap-6 text-left"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
<Trans i18nKey="account:consents" />
</h2>
<Card>
<div className="flex items-center justify-between p-3">
<div>
<CardTitle className="text-base">
<Trans i18nKey="account:consentToAnonymizedCompanyData.label" />
</CardTitle>
<CardDescription>
<Trans i18nKey="account:consentToAnonymizedCompanyData.description" />
</CardDescription>
</div>
<Switch
checked={!!watchedConsent}
onCheckedChange={(checked) =>
setValue('isConsentToAnonymizedCompanyStatistics', checked)
}
{...register('isConsentToAnonymizedCompanyStatistics')}
/>
</div>
</Card>
</div>
<Button type="submit" className="w-36">
<Trans i18nKey="account:updateProfileSubmitLabel" />
</Button>
</form>
</Form>
</>
);
}

View File

@@ -0,0 +1,259 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
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,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
import {
AccountSettings,
accountSettingsSchema,
} from '../_lib/account-settings.schema';
import { updatePersonalAccountAction } from '../_lib/server/actions';
export default function AccountSettingsForm({
account,
}: {
account: AccountWithParams | null;
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const form = useForm({
resolver: zodResolver(accountSettingsSchema),
defaultValues: {
firstName: account?.name,
lastName: account?.last_name ?? '',
email: account?.email,
phone: account?.phone ?? '',
accountParams: {
height: account?.accountParams?.height,
weight: account?.accountParams?.weight,
isSmoker: account?.accountParams?.isSmoker,
},
},
});
const { handleSubmit } = form;
const onSubmit = async (data: AccountSettings) => {
if (!account?.id) {
return toast.error(<Trans i18nKey="account:updateAccountError" />);
}
const result = await updatePersonalAccountAction({
accountId: account.id,
data,
});
if (result.success) {
revalidateUserDataQuery(account.primary_owner_user_id);
return toast.success(<Trans i18nKey="account:updateAccountSuccess" />);
}
return toast.error(<Trans i18nKey="account:updateAccountError" />);
};
if (!account) return null;
return (
<Form {...form}>
<form
className="flex flex-col gap-6 text-left"
onSubmit={handleSubmit(onSubmit)}
>
<FormField
name={'firstName'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.firstName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'lastName'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.lastName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
name={'accountParams.height'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.height'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'accountParams.weight'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.weight'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<FormField
name={'phone'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.phone'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'email'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.email'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
<Trans i18nKey="account:myHabits" />
</h2>
<FormField
name="accountParams.isSmoker"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.smoking'} />
</FormLabel>
<FormControl>
<Select
value={
field.value === true
? 'yes'
: field.value === false
? 'no'
: 'preferNotToAnswer'
}
onValueChange={(value) => {
if (value === 'yes') {
field.onChange(true);
} else if (value === 'no') {
field.onChange(false);
} else {
field.onChange(null);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">
<Trans i18nKey="common:yes" />
</SelectItem>
<SelectItem value="no">
<Trans i18nKey="common:no" />
</SelectItem>
<SelectItem value="preferNotToAnswer">
<Trans i18nKey="common:preferNotToAnswer" />
</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-36">
<Trans i18nKey="account:updateProfileSubmitLabel" />
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { StoreCart } from '@medusajs/types';
import { Cross, Menu, Shield, ShoppingCart } from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
import { pathsConfig } from '@kit/shared/config';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import { UserWorkspace } from '../../_lib/server/load-user-workspace';
import { routes } from './settings-sidebar';
export function SettingsMobileNavigation(props: {
workspace: UserWorkspace;
cart: StoreCart | null;
}) {
const user = props.workspace.user;
const signOut = useSignOut();
const { data: personalAccountData } = usePersonalAccountData(user.id);
const Links = [
{
children: [{ path: pathsConfig.app.home, label: 'common:routes.home' }],
},
]
.concat(routes)
.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
const hasTotpFactor = useMemo(() => {
const factors = user?.factors ?? [];
return factors.some(
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
);
}, [user?.factors]);
const isSuperAdmin = useMemo(() => {
const hasAdminRole =
personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin;
return hasAdminRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
const isDoctor = useMemo(() => {
const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={props.cart && hasCartItems}>
<DropdownMenuGroup>
<DropdownLink
path={pathsConfig.app.cart}
label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartQuantityTotal }}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.admin}
>
<Shield className={'h-5'} />
<span>Super Admin</span>
</Link>
</DropdownMenuItem>
</If>
<If condition={isDoctor}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
'flex h-full cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.doctor}
>
<Cross className={'h-5'} />
<span>
<Trans i18nKey="common:doctor" />
</span>
</Link>
</DropdownMenuItem>
</If>
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,22 @@
import { Separator } from "@kit/ui/separator";
import { Trans } from "@kit/ui/trans";
export default function SettingsSectionHeader({
titleKey,
descriptionKey,
}: {
titleKey: string;
descriptionKey: string;
}) {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-gray-900">
<Trans i18nKey={titleKey} />
</h1>
<p className="text-gray-600">
<Trans i18nKey={descriptionKey} />
</p>
<Separator />
</div>
);
}

View File

@@ -0,0 +1,55 @@
import z from 'zod';
import { pathsConfig } from '@kit/shared/config';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { PageHeader } from '@kit/ui/page';
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarNavigation,
} from '@kit/ui/shadcn-sidebar';
import { Trans } from '@kit/ui/trans';
export const routes = [
{
children: [
{
label: 'common:routes.profile',
path: pathsConfig.app.personalAccountSettings,
end: true,
},
{
label: 'common:routes.preferences',
path: pathsConfig.app.personalAccountPreferences,
end: true,
},
{
label: 'common:routes.security',
path: pathsConfig.app.personalAccountSecurity,
end: true,
},
],
},
] satisfies z.infer<typeof NavigationConfigSchema>['routes'];
export function SettingsSidebar() {
return (
<Sidebar>
<SidebarHeader className="mt-16 h-24 w-[95vw] max-w-screen justify-center border-b bg-white pt-2">
<PageHeader
title={<Trans i18nKey="account:accountTabLabel" />}
description={<Trans i18nKey={'account:accountTabDescription'} />}
/>
</SidebarHeader>
<SidebarContent className="w-auto">
<SidebarNavigation
config={{ style: 'custom', sidebarCollapsedStyle: 'none', routes }}
/>
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const accountPreferencesSchema = z.object({
preferredLanguage: z.enum(['et', 'en', 'ru']).optional().nullable(),
isConsentToAnonymizedCompanyStatistics: z.boolean().optional(),
});
export type AccountPreferences = z.infer<typeof accountPreferencesSchema>;

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
export const accountSettingsSchema = z.object({
firstName: z
.string()
.min(1, { error: 'error:tooShort' })
.max(200, { error: 'error:tooLong' }),
lastName: z
.string()
.min(1, { error: 'error:tooShort' })
.max(200, { error: 'error:tooLong' }),
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' }),
isSmoker: z.boolean().optional().nullable(),
}),
});
export type AccountSettings = z.infer<typeof accountSettingsSchema>;

View File

@@ -0,0 +1,66 @@
'use server';
import z from 'zod';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import {
updatePersonalAccount,
updatePersonalAccountPreferences,
} from '~/lib/services/account.service';
import {
AccountPreferences,
accountPreferencesSchema,
} from '../account-preferences.schema';
import {
AccountSettings,
accountSettingsSchema,
} from '../account-settings.schema';
export const updatePersonalAccountAction = enhanceAction(
async ({ accountId, data }: { accountId: string; data: AccountSettings }) => {
const logger = await getLogger();
try {
logger.info({ accountId }, 'Updating account');
await updatePersonalAccount(accountId, data);
logger.info({ accountId }, 'Successfully updated account');
return { success: true };
} catch (e) {
logger.error('Failed to update account', JSON.stringify(e));
return { success: false };
}
},
{
schema: z.object({ accountId: z.uuid(), data: accountSettingsSchema }),
},
);
export const updatePersonalAccountPreferencesAction = enhanceAction(
async ({
accountId,
data,
}: {
accountId: string;
data: AccountPreferences;
}) => {
const logger = await getLogger();
try {
logger.info({ accountId }, 'Updating account preferences');
await updatePersonalAccountPreferences(accountId, data);
logger.info({ accountId }, 'Successfully updated account preferences');
return { success: true };
} catch (e) {
logger.error('Failed to update account preferences', JSON.stringify(e));
return { success: false };
}
},
{
schema: z.object({ accountId: z.uuid(), data: accountPreferencesSchema }),
},
);

View File

@@ -1,23 +1,69 @@
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Trans } from '@kit/ui/trans';
import { use } from 'react';
import { retrieveCart } from '@lib/data/cart';
import { StoreCart } from '@medusajs/types';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { AppLogo } from '@kit/shared/components/app-logo';
import { pathsConfig } from '@kit/shared/config';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SettingsSidebar } from './_components/settings-sidebar';
// home imports
import { HomeMenuNavigation } from '../_components/home-menu-navigation';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { SettingsMobileNavigation } from './_components/settings-navigation';
// local imports
import { HomeLayoutPageHeader } from '../_components/home-page-header';
function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'account:routes.settings'} />}
description={<AppBreadcrumbs />}
/>
{props.children}
</>
);
function UserSettingsLayout({ children }: React.PropsWithChildren) {
return <HeaderLayout>{children}</HeaderLayout>;
}
export default withI18n(UserSettingsLayout);
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart());
return (
<UserWorkspaceContextProvider value={workspace}>
<Page style={'header'}>
<PageNavigation>
<HomeMenuNavigation workspace={workspace} cart={cart} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} cart={cart} />
</PageMobileNavigation>
<SidebarProvider defaultOpen>
<Page style={'sidebar'}>
<PageNavigation>
<SettingsSidebar />
</PageNavigation>
<div className="md:mt-28 min-w-full min-h-full">{children}</div>
</Page>
</SidebarProvider>
</Page>
</UserWorkspaceContextProvider>
);
}
function MobileNavigation({
workspace,
cart,
}: {
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
cart: StoreCart | null;
}) {
return (
<>
<AppLogo href={pathsConfig.app.home} />
<SettingsMobileNavigation workspace={workspace} cart={cart} />
</>
);
}

View File

@@ -1,28 +1,11 @@
import { use } from 'react';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
import {
authConfig,
featureFlagsConfig,
pathsConfig,
} from '@kit/shared/config';
import { PageBody } from '@kit/ui/page';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
import { withI18n } from '~/lib/i18n/with-i18n';
const features = {
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
enablePasswordUpdate: authConfig.providers.password,
};
const callbackPath = pathsConfig.auth.callback;
const accountHomePath = pathsConfig.app.accountHome;
const paths = {
callback: callbackPath + `?next=${accountHomePath}`,
};
import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
import AccountSettingsForm from './_components/account-settings-form';
import SettingsSectionHeader from './_components/settings-section-header';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
@@ -33,17 +16,18 @@ export const generateMetadata = async () => {
};
};
function PersonalAccountSettingsPage() {
const user = use(requireUserInServerComponent());
async function PersonalAccountSettingsPage() {
const account = await loadCurrentUserAccount();
return (
<PageBody>
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
<PersonalAccountSettingsContainer
userId={user.id}
features={features}
paths={paths}
/>
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
<SettingsSectionHeader
titleKey="account:accountTabLabel"
descriptionKey="account:accountTabDescription"
/>
<AccountSettingsForm account={account} />
</div>
</div>
</PageBody>
);

View File

@@ -0,0 +1,24 @@
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();
return (
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
<SettingsSectionHeader
titleKey="account:preferencesTabLabel"
descriptionKey="account:preferencesTabDescription"
/>
<AccountPreferencesForm account={account} />
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { MultiFactorAuthFactorsList } from '@kit/accounts/components';
import SettingsSectionHeader from '../_components/settings-section-header';
export default function SecuritySettingsPage() {
return (
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
<SettingsSectionHeader
titleKey="account:securityTabLabel"
descriptionKey="account:securityTabDescription"
/>
<MultiFactorAuthFactorsList />
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
'use client';
import Link from 'next/link';
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import { Home, Menu } from 'lucide-react';
import { AccountSelector } from '@kit/accounts/account-selector';
import {
@@ -92,47 +93,7 @@ export const TeamAccountLayoutMobileNavigation = (
);
};
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem asChild>
<Link
href={props.path}
className={'flex h-12 w-full items-center gap-x-3 px-3'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
</DropdownMenuItem>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onClick={props.onSignOut}
>
<LogOut className={'h-4'} />
<span>
<Trans i18nKey={'common:signOut'} />
</span>
</DropdownMenuItem>
);
}
function TeamAccountsModal(props: {
accounts: Accounts;