Merge branch 'main' into feature/MED-100

This commit is contained in:
2025-07-21 11:38:47 +03:00
46 changed files with 6714 additions and 501 deletions

View File

@@ -10,7 +10,7 @@ import CompanyOfferForm from './_components/company-offer-form';
function CompanyOffer() {
return (
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
<div className="flex w-1/2 flex-col px-12 py-14 text-center">
<div className="flex flex-col px-12 py-14 text-center md:w-1/2">
<MedReportLogo />
<h1 className="pt-8">
<Trans i18nKey={'account:requestCompanyAccount:title'} />
@@ -20,7 +20,7 @@ function CompanyOffer() {
</p>
<CompanyOfferForm />
</div>
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></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

@@ -0,0 +1,226 @@
'use client';
import Link from 'next/link';
import { User } from '@supabase/supabase-js';
import { ExternalLink } from '@/public/assets/external-link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
import { onUpdateAccount } from '../_lib/server/update-account';
export function UpdateAccountForm({ user }: { user: User }) {
const form = useForm({
resolver: zodResolver(UpdateAccountSchema),
mode: 'onChange',
defaultValues: {
firstName: '',
lastName: '',
personalCode: user.user_metadata.personalCode ?? '',
email: user.email,
phone: '',
city: '',
weight: user.user_metadata.weight ?? undefined,
height: user.user_metadata.height ?? undefined,
userConsent: false,
},
});
return (
<Form {...form}>
<form
className="flex flex-col gap-6 px-6 pt-10 text-left"
onSubmit={form.handleSubmit(onUpdateAccount)}
>
<FormField
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:firstName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:lastName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="personalCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:personalCode'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:email'} />
</FormLabel>
<FormControl>
<Input {...field} disabled />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:phone'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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>
)}
/>
<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"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center gap-2 pb-1">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>
<Trans i18nKey={'account:updateAccount:userConsentLabel'} />
</FormLabel>
</div>
<Link
href={''}
className="flex flex-row items-center gap-2 text-sm hover:underline"
target="_blank"
>
<ExternalLink />
<Trans i18nKey={'account:updateAccount:userConsentUrlTitle'} />
</Link>
</FormItem>
)}
/>
<Button disabled={form.formState.isSubmitting} type="submit">
<Trans i18nKey={'account:updateAccount:button'} />
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
export const UpdateAccountSchema = z.object({
firstName: z
.string({
required_error: 'First name is required',
})
.nonempty(),
lastName: z
.string({
required_error: 'Last name is required',
})
.nonempty(),
personalCode: z
.string({
required_error: 'Personal code is required',
})
.nonempty(),
email: z.string().email({
message: 'Email is required',
}),
phone: z
.string({
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',
})
.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',
})
.gt(0, { message: 'Height must be greater than 0' }),
userConsent: z.boolean().refine((val) => val === true, {
message: 'Must be true',
}),
});

View File

@@ -0,0 +1,59 @@
'use server';
import { redirect } from 'next/navigation';
import { createAuthApi } from '@kit/auth/api';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
import { updateCustomer } from '@lib/data/customer';
import { UpdateAccountSchema } from '../schemas/update-account.schema';
export interface AccountSubmitData {
firstName: string;
lastName: string;
personalCode: string;
email: string;
phone?: string;
city?: string;
weight: number | null;
height: number | null;
userConsent: boolean;
}
export const onUpdateAccount = enhanceAction(
async (params: AccountSubmitData) => {
const client = getSupabaseServerClient();
const api = createAuthApi(client);
try {
await api.updateAccount(params);
console.log('SUCCESS', pathsConfig.auth.updateAccountSuccess);
} catch (err: unknown) {
if (err instanceof Error) {
console.warn('On update account error: ' + err.message);
}
console.warn('On update account error: ', err);
}
await updateCustomer({
first_name: params.firstName,
last_name: params.lastName,
phone: params.phone,
});
const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation();
if (hasUnseenMembershipConfirmation) {
redirect(pathsConfig.auth.membershipConfirmation);
} else {
redirect(pathsConfig.app.selectPackage);
}
},
{
schema: UpdateAccountSchema,
},
);

View File

@@ -4,13 +4,14 @@ import { BackButton } from '@/components/back-button';
import { MedReportLogo } from '@/components/med-report-logo';
import pathsConfig from '@/config/paths.config';
import { signOutAction } from '@/lib/actions/sign-out';
import { UpdateAccountForm } from '@/packages/features/auth/src/components/update-account-form';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { UpdateAccountForm } from './_components/update-account-form';
async function UpdateAccount() {
const client = getSupabaseServerClient();
@@ -24,7 +25,7 @@ async function UpdateAccount() {
return (
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
<div className="relative flex w-1/2 min-w-md flex-col px-12 pt-7 pb-22 text-center">
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
<BackButton onBack={signOutAction} />
<MedReportLogo />
<h1 className="pt-8">
@@ -35,7 +36,7 @@ async function UpdateAccount() {
</p>
<UpdateAccountForm user={user} />
</div>
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></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

@@ -0,0 +1,89 @@
import React from 'react';
import { ArrowDown } from 'lucide-react';
import { cn } from '@kit/ui/utils';
export enum AnalysisResultLevel {
VERY_LOW = 0,
LOW = 1,
NORMAL = 2,
HIGH = 3,
VERY_HIGH = 4,
}
const Level = ({
isActive = false,
color,
isFirst = false,
isLast = false,
}: {
isActive?: boolean;
color: 'destructive' | 'success' | 'warning';
isFirst?: boolean;
isLast?: boolean;
}) => {
return (
<div
className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-20': !isActive,
'rounded-l-lg': isFirst,
'rounded-r-lg': isLast,
})}
>
{isActive && (
<div className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]">
<ArrowDown strokeWidth={2} />
</div>
)}
</div>
);
};
const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level,
}: {
normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel;
}) => {
return (
<div className="mt-4 flex h-3 w-full max-w-[360px] gap-1 sm:mt-0">
{normLowerIncluded && (
<>
<Level
isActive={level === AnalysisResultLevel.VERY_LOW}
color="destructive"
isFirst
/>
<Level isActive={level === AnalysisResultLevel.LOW} color="warning" />
</>
)}
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
isActive={level === AnalysisResultLevel.NORMAL}
color="success"
/>
{normUpperIncluded && (
<>
<Level
isActive={level === AnalysisResultLevel.HIGH}
color="warning"
/>
<Level
isActive={level === AnalysisResultLevel.VERY_HIGH}
color="destructive"
isLast
/>
</>
)}
</div>
);
};
export default AnalysisLevelBar;

View File

@@ -0,0 +1,98 @@
'use client';
import React, { useState } from 'react';
import { Info } from 'lucide-react';
import { cn } from '@kit/ui/utils';
import AnalysisLevelBar, { AnalysisResultLevel } from './analysis-level-bar';
export enum AnalysisStatus {
NORMAL = 0,
MEDIUM = 1,
HIGH = 2,
}
const Analysis = ({
analysis: {
name,
status,
unit,
value,
normLowerIncluded,
normUpperIncluded,
normLower,
normUpper,
},
}: {
analysis: {
name: string;
status: AnalysisStatus;
unit: string;
value: number;
normLowerIncluded: boolean;
normUpperIncluded: boolean;
normLower: number;
normUpper: number;
};
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const isUnderNorm = value < normLower;
const getAnalysisResultLevel = () => {
if (isUnderNorm) {
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.LOW;
default:
return AnalysisResultLevel.VERY_LOW;
}
}
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.HIGH;
case AnalysisStatus.HIGH:
return AnalysisResultLevel.VERY_HIGH;
default:
return AnalysisResultLevel.NORMAL;
}
};
return (
<div className="border-border grid grid-cols-2 items-center justify-between rounded-lg border px-5 py-3 sm:flex">
<div className="flex items-center gap-2 font-semibold">
{name}
<div
className="group/tooltip relative"
onClick={() => setShowTooltip(!showTooltip)}
onMouseLeave={() => setShowTooltip(false)}
>
<Info className="hover" />{' '}
<div
className={cn(
'absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded border bg-white p-4 text-sm whitespace-nowrap group-hover/tooltip:block',
{ block: showTooltip },
)}
>
This text changes when you hover the box above.
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="font-semibold">{value}</div>
<div className="text-muted-foreground text-sm">{unit}</div>
</div>
<div className="text-muted-foreground mt-4 flex gap-2 text-center text-sm sm:mt-0 sm:block sm:gap-0">
{normLower} - {normUpper}
<div>Normaalne vahemik</div>
</div>
<AnalysisLevelBar
normLowerIncluded={normLowerIncluded}
normUpperIncluded={normUpperIncluded}
level={getAnalysisResultLevel()}
/>
</div>
);
};
export default Analysis;

View File

@@ -0,0 +1,59 @@
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '@/lib/i18n/with-i18n';
import { Trans } from '@kit/ui/makerkit/trans';
import { PageBody } from '@kit/ui/page';
import { Button } from '@kit/ui/shadcn/button';
import { loadUserAnalysis } from '../../_lib/server/load-user-analysis';
import Analysis, { AnalysisStatus } from './_components/analysis';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('account:analysisResults.pageTitle');
return {
title,
};
};
async function AnalysisResultsPage() {
const analysisList = await loadUserAnalysis();
return (
<PageBody>
<div className="mt-8 flex items-center justify-between">
<div>
<h4>
<Trans i18nKey="account:analysisResults.pageTitle" />
</h4>
<p className="text-muted-foreground text-sm">
<Trans i18nKey="account:analysisResults.description" />
</p>
</div>
<Button>
<Trans i18nKey="account:analysisResults.orderNewAnalysis" />
</Button>
</div>
<div className="flex flex-col gap-2">
{analysisList?.map((analysis, index) => (
<Analysis
key={index}
analysis={{
name: analysis.element.analysis_name || '',
status: analysis.element.norm_status as AnalysisStatus,
unit: analysis.element.unit || '',
value: analysis.element.response_value,
normLowerIncluded: !!analysis.element.norm_lower_included,
normUpperIncluded: !!analysis.element.norm_upper_included,
normLower: analysis.element.norm_lower || 0,
normUpper: analysis.element.norm_upper || 0,
}}
/>
))}
</div>
</PageBody>
);
}
export default withI18n(AnalysisResultsPage);

View File

@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { InfoTooltip } from '@/components/ui/info-tooltip';
import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons';
import {
@@ -130,7 +131,7 @@ const dummyRecommendations = [
export default function Dashboard() {
return (
<>
<div className="grid auto-rows-fr grid-cols-5 gap-3">
<div className="grid auto-rows-fr grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
{dummyCards.map(
({
title,
@@ -190,7 +191,7 @@ export default function Dashboard() {
) => {
return (
<div className="flex justify-between" key={index}>
<div className="flex flex-row items-center gap-4">
<div className="mr-4 flex flex-row items-center gap-4">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',

View File

@@ -0,0 +1,22 @@
import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api';
import { UserAnalysis } from '@kit/accounts/types/accounts';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type UserAccount = Awaited<ReturnType<typeof loadUserAnalysis>>;
/**
* @name loadUserAccount
* @description
* Load the user account. It's a cached per-request function that fetches the user workspace data.
* It can be used across the server components to load the user workspace data.
*/
export const loadUserAnalysis = cache(analysisLoader);
async function analysisLoader(): Promise<UserAnalysis | null> {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
return api.getUserAnalysis();
}

View File

@@ -1,14 +1,14 @@
import Link from 'next/link';
import SelectAnalysisPackages from '@/components/select-analysis-packages';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Scale } from 'lucide-react';
import { Trans } from '@kit/ui/trans';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import SelectAnalysisPackages from '@/components/select-analysis-packages';
import { MedReportLogo } from '../../components/med-report-logo';
import pathsConfig from '../../config/paths.config';
@@ -44,12 +44,14 @@ async function SelectPackagePage() {
/>
</div>
<SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
<Link href={pathsConfig.app.home}>
<Button variant="secondary" className="align-center">
<Trans i18nKey={'marketing:notInterestedInAudit'} />{' '}
<CaretRightIcon className="size-4" />
</Button>
</Link>
<div className="flex justify-center">
<Link href={pathsConfig.app.home}>
<Button variant="secondary" className="align-center">
<Trans i18nKey="marketing:notInterestedInAudit" />{' '}
<CaretRightIcon className="size-4" />
</Button>
</Link>
</div>
</div>
);
}