10 Commits

Author SHA1 Message Date
ab92c3b4d0 test3 2025-09-08 00:47:04 +03:00
e324872c3c test2 2025-09-08 00:46:45 +03:00
a44f9c9207 test 2025-09-08 00:45:43 +03:00
0cf04b4f55 wip 2025-09-05 01:39:06 +03:00
84216c3ced test 2025-09-04 13:40:21 +03:00
0037241558 allow transferCart to fail on register 2025-09-04 13:36:46 +03:00
c7f89723e3 allow transferCart to fail on login/register 2025-09-04 13:19:09 +03:00
4a06059a25 medusa product can have either analysiselement or analysis originalId 2025-09-04 13:18:42 +03:00
a0abb44257 hide dashboard recommendations block 2025-09-04 12:57:18 +03:00
283b502963 fix tooltip should wrap long text 2025-09-04 12:41:18 +03:00
768 changed files with 64741 additions and 29710 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
.git
Dockerfile

5
.env
View File

@@ -33,7 +33,7 @@ NEXT_PUBLIC_LOCALES_PATH=apps/web/public/locales
NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
@@ -68,6 +68,3 @@ NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
# Configure Medusa password secret for Keycloak users # Configure Medusa password secret for Keycloak users
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg== MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==
# False by default
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false

View File

@@ -31,20 +31,16 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
MEDIPOST_USER=trvurgtst MEDIPOST_USER=trvurgtst
MEDIPOST_PASSWORD=SRB48HZMV MEDIPOST_PASSWORD=SRB48HZMV
MEDIPOST_RECIPIENT=syndev
MEDIPOST_RECIPIENT=trvurgtst MEDIPOST_RECIPIENT=trvurgtst
MEDIPOST_MESSAGE_SENDER=trvurgtst MEDIPOST_MESSAGE_SENDER=trvurgtst
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true
#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet #MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
#MEDIPOST_USER=medreport #MEDIPOST_USER=medreport
#MEDIPOST_PASSWORD=85MXFFDB7 #MEDIPOST_PASSWORD=85MXFFDB7
#MEDIPOST_RECIPIENT=HTI #MEDIPOST_RECIPIENT=HTI
#MEDIPOST_MESSAGE_SENDER=medreport #MEDIPOST_MESSAGE_SENDER=medreport
#MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false
# MEDUSA # MEDUSA
COMPANY_BENEFITS_PAYMENT_SECRET_KEY=NzcwMzE2NmEtOThiMS0xMWYwLWI4NjYtMDMwZDQzMjFhMjExCg==
MEDUSA_BACKEND_URL=http://localhost:9000 MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000 MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
MEDUSA_SECRET_API_KEY=sk_b332d525212ab4078ef73fb2b8232c3beebccc4a460e2c7abf6e187a458d60cf MEDUSA_SECRET_API_KEY=sk_b332d525212ab4078ef73fb2b8232c3beebccc4a460e2c7abf6e187a458d60cf
@@ -52,8 +48,8 @@ NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb
#MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee #MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee #MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
#MEDUSA_SECRET_API_KEY=sk_5ac1c1c12c144cd744b6c881050d459e339ddf6a3d14eda271a0cc4f9d3812cb #MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e740b9ca22b31c4b44862044f001dbcf8f46d47d40f430733d0c75bef14d2d6a #NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_827a2ab863021cb67993f1d81078f81bfce4b4e0da642d8c0f5398ded9d8fd32
#MEDUSA_BACKEND_URL=https://backoffice.medreport.ee #MEDUSA_BACKEND_URL=https://backoffice.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice.medreport.ee #MEDUSA_BACKEND_PUBLIC_URL=https://backoffice.medreport.ee
@@ -85,10 +81,10 @@ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhY
#NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0 #NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
#SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk #SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321 #NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 #NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU #SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
### TEST.MEDREPORT.ee ### ### TEST.MEDREPORT.ee ###
DB_PASSWORD=imCTUreSnazWKT3u# DB_PASSWORD=T#u-$M7%RjbA@L@

View File

@@ -11,5 +11,5 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzd
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# # MONTONIO # MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

View File

@@ -6,10 +6,10 @@
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE. ## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
# SUPABASE # SUPABASE
# NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co NEXT_PUBLIC_SUPABASE_URL=https://kaldvociniytdbbcxvqk.supabase.co
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImthbGR2b2Npbml5dGRiYmN4dnFrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYzNjQ5OTYsImV4cCI6MjA3MTk0MDk5Nn0.eixihH2KGkJZolY9FiQDicJOo2kxvXrSe6gGUCrkLo0
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# # MONTONIO # MONTONIO
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

1
.npmrc
View File

@@ -3,7 +3,6 @@ dedupe-peer-dependents=true
use-lockfile-v6=true use-lockfile-v6=true
resolution-mode=highest resolution-mode=highest
package-manager-strict=true package-manager-strict=true
ignore-workspace-root-check=true
public-hoist-pattern[]=*i18next* public-hoist-pattern[]=*i18next*
public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier* public-hoist-pattern[]=*prettier*

View File

@@ -22,12 +22,12 @@ COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
RUN set -a \ RUN set -a \
&& . .env \ && . .env \
&& . .env.production \ && . .env.production \
&& . .env.staging \ && . .env.staging \
&& set +a \ && set +a \
&& node check-env.js \ && node check-env.js \
&& pnpm build && pnpm build
# --- Stage 2: Runtime --- # --- Stage 2: Runtime ---
@@ -41,13 +41,13 @@ COPY --from=builder /app ./
RUN cp ".env.${APP_ENV}" .env.local RUN cp ".env.${APP_ENV}" .env.local
RUN npm install -g pnpm@9 \ RUN npm install -g pnpm@9 \
&& pnpm install --prod --frozen-lockfile && pnpm install --prod --frozen-lockfile
ENV NODE_ENV=production ENV NODE_ENV=production
# 🔍 Optional: Log key envs for debug # 🔍 Optional: Log key envs for debug
RUN echo "📄 .env contents:" && cat .env.local \ RUN echo "📄 .env contents:" && cat .env.local \
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true && echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
EXPOSE 3000 EXPOSE 3000

View File

@@ -47,18 +47,6 @@ if you get missing dependency error do `pnpm i --force`
pnpm add <pacakge-name> -w pnpm add <pacakge-name> -w
``` ```
## Eslint and prettier
To clean code run
```bash
npm run lint:fix
```
```bash
npm run prettier:fix
```
## Supabase ## Supabase
Start supabase in docker Start supabase in docker

View File

@@ -1,44 +0,0 @@
// Mock for isikukood library to avoid ES module issues in tests
export enum Gender {
MALE = 'male',
FEMALE = 'female',
}
export default class Isikukood {
private code: string;
constructor(code: string) {
this.code = code;
}
static validate(code: string): boolean {
return true; // Mock always returns true for tests
}
static generate(options?: { gender?: Gender; century?: number }): string {
return '39001010002'; // Mock Estonian ID code
}
isValid(): boolean {
return true;
}
getGender(): Gender {
return Gender.MALE;
}
getBirthDate(): Date {
return new Date('1990-01-01');
}
getAge(): number {
return 30;
}
getCentury(): number {
return 3;
}
}
export { Isikukood };

View File

@@ -1,3 +0,0 @@
// Mock for server-only to avoid Next.js server component issues in tests
// This module does nothing in tests - it's just a marker for Next.js
export {};

View File

@@ -2,6 +2,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -2,6 +2,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -7,14 +7,14 @@ import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { useAuthConfig } from '@kit/shared/hooks';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
const ModeToggle = dynamic(() => const ModeToggle = dynamic(() =>
import('@kit/ui/mode-toggle').then((mod) => ({ import('@kit/ui/mode-toggle').then((mod) => ({
default: mod.ModeToggle, default: mod.ModeToggle,
@@ -57,8 +57,6 @@ export function SiteHeaderAccountSection({
} }
function AuthButtons() { function AuthButtons() {
const { config } = useAuthConfig();
return ( return (
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}> <div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
<div className={'hidden md:flex'}> <div className={'hidden md:flex'}>
@@ -67,25 +65,21 @@ function AuthButtons() {
</If> </If>
</div> </div>
{config && ( <div className={'flex gap-x-2.5'}>
<div className={'flex gap-x-2.5'}> <Button className={'block'} asChild variant={'ghost'}>
{(config.providers.password || config.providers.oAuth.length > 0) && ( <Link href={pathsConfig.auth.signIn}>
<Button className={'block'} asChild variant={'ghost'}> <Trans i18nKey={'auth:signIn'} />
<Link href={pathsConfig.auth.signIn}> </Link>
<Trans i18nKey={'auth:signIn'} /> </Button>
</Link>
</Button>
)}
{config.providers.password && ( {authConfig.providers.password && (
<Button asChild className="text-xs md:text-sm" variant={'default'}> <Button asChild className="text-xs md:text-sm" variant={'default'}>
<Link href={pathsConfig.auth.signUp}> <Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth:signUp'} /> <Trans i18nKey={'auth:signUp'} />
</Link> </Link>
</Button> </Button>
)} )}
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -1,8 +1,9 @@
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace'; import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { AppLogo } from '@kit/shared/components/app-logo';
import { Header } from '@kit/ui/marketing'; import { Header } from '@kit/ui/marketing';
import { AppLogo } from '@kit/shared/components/app-logo';
import { SiteHeaderAccountSection } from './site-header-account-section'; import { SiteHeaderAccountSection } from './site-header-account-section';
export function SiteHeader({ export function SiteHeader({

View File

@@ -8,6 +8,7 @@ import { createCmsClient } from '@kit/cms';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { Post } from '../../blog/_components/post'; import { Post } from '../../blog/_components/post';
interface BlogPageProps { interface BlogPageProps {

View File

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

View File

@@ -6,6 +6,7 @@ import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -9,6 +9,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -2,9 +2,10 @@ import { use } from 'react';
import { SiteFooter } from '~/(marketing)/_components/site-footer'; import { SiteFooter } from '~/(marketing)/_components/site-footer';
import { SiteHeader } from '~/(marketing)/_components/site-header'; import { SiteHeader } from '~/(marketing)/_components/site-header';
import { loadCurrentUserAccounts } from '~/home/(user)/_lib/server/load-accounts';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { loadCurrentUserAccounts } from '~/home/(user)/_lib/server/load-accounts';
function SiteLayout(props: React.PropsWithChildren) { function SiteLayout(props: React.PropsWithChildren) {
const accounts = use(loadCurrentUserAccounts()); const accounts = use(loadCurrentUserAccounts());
return ( return (

View File

@@ -1,9 +1,9 @@
import Link from 'next/link'; import Link from 'next/link';
import { ArrowRightIcon } from 'lucide-react';
import { MedReportLogo } from '@kit/shared/components/med-report-logo'; import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { ArrowRightIcon } from 'lucide-react';
import { CtaButton, Hero } from '@kit/ui/marketing'; import { CtaButton, Hero } from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';

View File

@@ -1,9 +1,8 @@
import React from 'react'; import React from 'react';
import { MedReportLogo } from '@kit/shared/components/med-report-logo'; import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { Trans } from '@kit/ui/trans';
import CompanyOfferForm from './_components/company-offer-form'; import CompanyOfferForm from './_components/company-offer-form';

View File

@@ -1,8 +1,9 @@
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
function SiteLayout(props: React.PropsWithChildren) { function SiteLayout(props: React.PropsWithChildren) {
return ( return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}> <div className={'flex min-h-[100vh] flex-col justify-center items-center'}>
{props.children} {props.children}
</div> </div>
); );

View File

@@ -1,24 +0,0 @@
import { SIDEBAR_WIDTH_PROPERTY } from '@/packages/ui/src/shadcn/constants';
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import type { UserWorkspace } from '../../home/(user)/_lib/server/load-user-workspace';
export function AdminMenuNavigation(props: { workspace: UserWorkspace }) {
const { accounts } = props.workspace;
return (
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
<div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}>
<AppLogo href={'/admin'} />
</div>
<div className="flex items-center justify-end gap-3">
<div>
<ProfileAccountDropdownContainer accounts={accounts} />
</div>
</div>
</div>
);
}

View File

@@ -1,25 +1,73 @@
import { adminNavigationConfig } from '@kit/shared/config'; 'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { LayoutDashboard, Users } from 'lucide-react';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarNavigation, SidebarMenu,
SidebarMenuButton,
useSidebar,
} from '@kit/ui/shadcn-sidebar'; } from '@kit/ui/shadcn-sidebar';
export function AdminSidebar() { import { AppLogo } from '@kit/shared/components/app-logo';
const collapsible = adminNavigationConfig.sidebarCollapsedStyle; import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
export function AdminSidebar({
accounts,
}: {
accounts: UserWorkspace['accounts'];
}) {
const path = usePathname();
const { open } = useSidebar();
return ( return (
<Sidebar collapsible={collapsible}> <Sidebar collapsible="icon">
<SidebarHeader className="h-24 justify-center"> <SidebarHeader className={'m-2'}>
<div className="mt-24 flex items-center"> <AppLogo href={'/admin'} className="max-w-full" compact={!open} />
<h5>Superadmin</h5>
</div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarNavigation config={adminNavigationConfig} /> <SidebarGroup>
<SidebarGroupLabel>Super Admin</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuButton isActive={path === '/admin'} asChild>
<Link className={'flex gap-2.5'} href={'/admin'}>
<LayoutDashboard className={'h-4'} />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
<SidebarMenuButton
isActive={path.includes('/admin/accounts')}
asChild
>
<Link
className={'flex size-full gap-2.5'}
href={'/admin/accounts'}
>
<Users className={'h-4'} />
<span>Accounts</span>
</Link>
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter>
<ProfileAccountDropdownContainer accounts={accounts} />
</SidebarFooter>
</Sidebar> </Sidebar>
); );
} }

View File

@@ -2,8 +2,6 @@ import { cache } from 'react';
import { AdminAccountPage } from '@kit/admin/components/admin-account-page'; import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
import { AdminGuard } from '@kit/admin/components/admin-guard'; import { AdminGuard } from '@kit/admin/components/admin-guard';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getAccount } from '~/lib/services/account.service'; import { getAccount } from '~/lib/services/account.service';
interface Params { interface Params {
@@ -28,6 +26,6 @@ async function AccountPage(props: Params) {
return <AdminAccountPage account={account} />; return <AdminAccountPage account={account} />;
} }
export default withI18n(AdminGuard(AccountPage)); export default AdminGuard(AccountPage);
const loadAccount = cache(getAccount); const loadAccount = cache(getAccount);

View File

@@ -9,8 +9,6 @@ import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
interface SearchParams { interface SearchParams {
page?: string; page?: string;
account_type?: 'all' | 'team' | 'personal'; account_type?: 'all' | 'team' | 'personal';
@@ -32,7 +30,7 @@ async function AccountsPage(props: AdminAccountsPageProps) {
return ( return (
<> <>
<PageHeader description={<AppBreadcrumbs />} title="Accounts"> <PageHeader description={<AppBreadcrumbs />}>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<AdminCreateUserDialog> <AdminCreateUserDialog>
<Button data-test="admin-create-user-button"> <Button data-test="admin-create-user-button">
@@ -86,4 +84,4 @@ async function AccountsPage(props: AdminAccountsPageProps) {
); );
} }
export default withI18n(AdminGuard(AccountsPage)); export default AdminGuard(AccountsPage);

View File

@@ -5,7 +5,6 @@ import { cookies } from 'next/headers';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AdminMenuNavigation } from '~/admin/_components/admin-menu-navigation';
import { AdminSidebar } from '~/admin/_components/admin-sidebar'; import { AdminSidebar } from '~/admin/_components/admin-sidebar';
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation'; import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
@@ -22,24 +21,19 @@ export default function AdminLayout(props: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace()); const workspace = use(loadUserWorkspace());
return ( return (
<Page style={'header'}> <SidebarProvider defaultOpen={state.open}>
<PageNavigation> <Page style={'sidebar'}>
<AdminMenuNavigation workspace={workspace} /> <PageNavigation>
</PageNavigation> <AdminSidebar accounts={workspace.accounts} />
</PageNavigation>
<PageMobileNavigation> <PageMobileNavigation>
<AdminMobileNavigation /> <AdminMobileNavigation />
</PageMobileNavigation> </PageMobileNavigation>
<SidebarProvider defaultOpen={state.open}> {props.children}
<Page style={'sidebar'}> </Page>
<PageNavigation> </SidebarProvider>
<AdminSidebar />
</PageNavigation>
{props.children}
</Page>
</SidebarProvider>
</Page>
); );
} }

View File

@@ -2,12 +2,10 @@ import { AdminDashboard } from '@kit/admin/components/admin-dashboard';
import { AdminGuard } from '@kit/admin/components/admin-guard'; import { AdminGuard } from '@kit/admin/components/admin-guard';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
function AdminPage() { function AdminPage() {
return ( return (
<> <>
<PageHeader title={`Super Admin`} /> <PageHeader description={`Super Admin`} />
<PageBody> <PageBody>
<AdminDashboard /> <AdminDashboard />
@@ -16,4 +14,4 @@ function AdminPage() {
); );
} }
export default withI18n(AdminGuard(AdminPage)); export default AdminGuard(AdminPage);

View File

@@ -1,10 +1,11 @@
import { getPlanTypesMap } from '@kit/billing'; import { getPlanTypesMap } from '@kit/billing';
import { getBillingEventHandlerService } from '@kit/billing-gateway'; import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { enhanceRouteHandler } from '@kit/next/routes'; import { enhanceRouteHandler } from '@kit/next/routes';
import { billingConfig } from '@kit/shared/config';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { billingConfig } from '@kit/shared/config';
/** /**
* @description Handle the webhooks from Stripe related to checkouts * @description Handle the webhooks from Stripe related to checkouts
*/ */

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { sendEmail } from "~/lib/services/mailer.service";
export const GET = async () => {
const { renderInviteEmail } = await import('@kit/email-templates');
const html = await renderInviteEmail({
language: 'en',
teamName: 'Test Team',
invitedUserEmail: 'test@example.com',
productName: 'Test Product',
teamLogo: 'https://placehold.co/100x100',
inviter: 'John Doe',
link: 'https://www.google.com',
});
return NextResponse.json({
html,
length: html.html.length,
});
};

View File

@@ -2,11 +2,7 @@ import { config } from 'dotenv';
export default function loadEnv() { export default function loadEnv() {
config({ path: `.env` }); config({ path: `.env` });
if ( if (['local', 'test', 'development', 'production'].includes(process.env.NODE_ENV!)) {
['local', 'test', 'development', 'production'].includes(
process.env.NODE_ENV!,
)
) {
config({ path: `.env.${process.env.NODE_ENV}` }); config({ path: `.env.${process.env.NODE_ENV}` });
} }
} }

View File

@@ -1,13 +1,9 @@
import { listProductTypes } from '@lib/data/products'; import Medusa from "@medusajs/js-sdk"
import Medusa from '@medusajs/js-sdk'; import type { AdminProductCategory } from "@medusajs/types";
import type { AdminProductCategory } from '@medusajs/types'; import { listProductTypes } from "@lib/data/products";
import { getAnalysisElements } from "~/lib/services/analysis-element.service";
import { import { getAnalysisGroups } from "~/lib/services/analysis-group.service";
createMedusaSyncFailEntry, import { createMedusaSyncFailEntry, createMedusaSyncSuccessEntry } from "~/lib/services/analyses.service";
createMedusaSyncSuccessEntry,
} from '~/lib/services/analyses.service';
import { getAnalysisElements } from '~/lib/services/analysis-element.service';
import { getAnalysisGroups } from '~/lib/services/analysis-group.service';
const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services'; const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services';
const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis'; const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis';
@@ -16,8 +12,7 @@ const BASE_ANALYSIS_PRODUCT_HANDLE = 'analysis-base';
const getAdminSdk = () => { const getAdminSdk = () => {
const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!; const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!;
const medusaPublishableApiKey = const medusaPublishableApiKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!;
process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!;
const key = process.env.MEDUSA_SECRET_API_KEY!; const key = process.env.MEDUSA_SECRET_API_KEY!;
if (!medusaBackendUrl || !medusaPublishableApiKey) { if (!medusaBackendUrl || !medusaPublishableApiKey) {
@@ -28,14 +23,15 @@ const getAdminSdk = () => {
debug: process.env.NODE_ENV === 'development', debug: process.env.NODE_ENV === 'development',
apiKey: key, apiKey: key,
}); });
}; }
async function createProductCategories({ medusa }: { medusa: Medusa }) { async function createProductCategories({
const { product_categories: existingProductCategories } = medusa,
await medusa.admin.productCategory.list(); }: {
const parentCategory = existingProductCategories.find( medusa: Medusa;
({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE, }) {
); const { product_categories: existingProductCategories } = await medusa.admin.productCategory.list();
const parentCategory = existingProductCategories.find(({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE);
if (!parentCategory) { if (!parentCategory) {
throw new Error('Parent category not found'); throw new Error('Parent category not found');
@@ -50,12 +46,8 @@ async function createProductCategories({ medusa }: { medusa: Medusa }) {
for (const analysisGroup of analysisGroups) { for (const analysisGroup of analysisGroups) {
console.info(`Processing analysis group '${analysisGroup.name}'`); console.info(`Processing analysis group '${analysisGroup.name}'`);
const isExisting = existingProductCategories.find( const isExisting = existingProductCategories.find(({ name }) => name === analysisGroup.name);
({ name }) => name === analysisGroup.name, const isNewlyCreated = createdCategories.find(({ name }) => name === analysisGroup.name);
);
const isNewlyCreated = createdCategories.find(
({ name }) => name === analysisGroup.name,
);
if (isExisting || isNewlyCreated) { if (isExisting || isNewlyCreated) {
console.info(`Analysis group '${analysisGroup.name}' already exists`); console.info(`Analysis group '${analysisGroup.name}' already exists`);
continue; continue;
@@ -71,19 +63,18 @@ async function createProductCategories({ medusa }: { medusa: Medusa }) {
analysisGroupId: analysisGroup.id, analysisGroupId: analysisGroup.id,
}, },
}); });
console.info( console.info(`Successfully created category, id=${createResponse.product_category.id}`);
`Successfully created category, id=${createResponse.product_category.id}`,
);
createdCategories.push(createResponse.product_category); createdCategories.push(createResponse.product_category);
} }
} }
async function getChildProductCategories({ medusa }: { medusa: Medusa }) { async function getChildProductCategories({
const { product_categories: allCategories } = medusa,
await medusa.admin.productCategory.list(); }: {
const childCategories = allCategories.filter( medusa: Medusa;
({ parent_category_id }) => parent_category_id !== null, }) {
); const { product_categories: allCategories } = await medusa.admin.productCategory.list();
const childCategories = allCategories.filter(({ parent_category_id }) => parent_category_id !== null);
return childCategories; return childCategories;
} }
@@ -102,34 +93,34 @@ async function deleteProductCategories({
/** /**
* In case a reset is needed * In case a reset is needed
*/ */
async function deleteProducts({ medusa }: { medusa: Medusa }) { async function deleteProducts({
medusa,
}: {
medusa: Medusa;
}) {
const { products: existingProducts } = await medusa.admin.product.list({ const { products: existingProducts } = await medusa.admin.product.list({
fields: 'id,collection_id', fields: 'id,collection_id',
limit: 1000, limit: 1000,
}); });
await Promise.all( await Promise.all(existingProducts.filter((a) => !a.collection_id).map(({ id }) => medusa.admin.product.delete(id)));
existingProducts
.filter((a) => !a.collection_id)
.map(({ id }) => medusa.admin.product.delete(id)),
);
} }
async function getAnalysisPackagesType() { async function getAnalysisPackagesType() {
const { productTypes } = await listProductTypes(); const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find( const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE);
({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE,
);
if (!analysisPackagesType) { if (!analysisPackagesType) {
throw new Error('Synlab analysis packages type not found'); throw new Error('Synlab analysis packages type not found');
} }
return analysisPackagesType; return analysisPackagesType;
} }
async function getProductDefaultFields({ medusa }: { medusa: Medusa }) { async function getProductDefaultFields({
const baseProductsResponse = await medusa.admin.product.list({ medusa,
handle: BASE_ANALYSIS_PRODUCT_HANDLE, }: {
}); medusa: Medusa;
}) {
const baseProductsResponse = await medusa.admin.product.list({ handle: BASE_ANALYSIS_PRODUCT_HANDLE })
const baseProduct = baseProductsResponse.products[0]; const baseProduct = baseProductsResponse.products[0];
if (!baseProduct) { if (!baseProduct) {
throw new Error('Base product not found'); throw new Error('Base product not found');
@@ -151,36 +142,39 @@ async function getProductDefaultFields({ medusa }: { medusa: Medusa }) {
defaultSalesChannels, defaultSalesChannels,
defaultProductOption, defaultProductOption,
defaultProductVariant, defaultProductVariant,
}; }
} }
async function createProducts({ medusa }: { medusa: Medusa }) { async function createProducts({
const { product_categories: allCategories } = medusa,
await medusa.admin.productCategory.list(); }: {
medusa: Medusa;
}) {
const { product_categories: allCategories } = await medusa.admin.productCategory.list();
const [ const [
{ products: existingProducts }, { products: existingProducts },
analysisElements, analysisElements,
analysisPackagesType, analysisPackagesType,
{ defaultSalesChannels, defaultProductOption, defaultProductVariant }, {
defaultSalesChannels,
defaultProductOption,
defaultProductVariant,
}
] = await Promise.all([ ] = await Promise.all([
medusa.admin.product.list({ medusa.admin.product.list({
category_id: allCategories.map(({ id }) => id), category_id: allCategories.map(({ id }) => id),
}), }),
getAnalysisElements({ getAll: true }), getAnalysisElements({}),
getAnalysisPackagesType(), getAnalysisPackagesType(),
getProductDefaultFields({ medusa }), getProductDefaultFields({ medusa }),
]); ])
for (const analysisElement of analysisElements) { for (const analysisElement of analysisElements) {
const { analysis_id_original: originalId } = analysisElement; const { analysis_id_original: originalId } = analysisElement;
const isExisting = existingProducts.find( const isExisting = existingProducts.find(({ metadata }) => metadata?.analysisIdOriginal === originalId);
({ metadata }) => metadata?.analysisIdOriginal === originalId,
);
if (isExisting) { if (isExisting) {
console.info( console.info(`Analysis element '${analysisElement.analysis_name_lab}' already exists`);
`Analysis element '${analysisElement.analysis_name_lab}' already exists`,
);
continue; continue;
} }
const { analysis_name_lab: name } = analysisElement; const { analysis_name_lab: name } = analysisElement;
@@ -189,10 +183,7 @@ async function createProducts({ medusa }: { medusa: Medusa }) {
continue; continue;
} }
const category = allCategories.find( const category = allCategories.find(({ metadata }) => metadata?.analysisGroupId === analysisElement.parent_analysis_group_id);
({ metadata }) =>
metadata?.analysisGroupId === analysisElement.parent_analysis_group_id,
);
if (!category) { if (!category) {
console.error(`Category not found for analysis element '${name}'`); console.error(`Category not found for analysis element '${name}'`);
continue; continue;
@@ -236,9 +227,9 @@ export default async function syncAnalysisGroupsStore() {
// await deleteProductCategories({ medusa, categories }); // await deleteProductCategories({ medusa, categories });
// await deleteProducts({ medusa }); // await deleteProducts({ medusa });
// return; // return;
await createProducts({ medusa }); await createProducts({ medusa });
await createMedusaSyncSuccessEntry(); await createMedusaSyncSuccessEntry();
} catch (e) { } catch (e) {
await createMedusaSyncFailEntry(JSON.stringify(e)); await createMedusaSyncFailEntry(JSON.stringify(e));

View File

@@ -1,32 +1,20 @@
import axios from 'axios'; import axios from 'axios';
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
import fs from 'fs'; import fs from 'fs';
import { createAnalysisGroup, getAnalysisGroups } from '~/lib/services/analysis-group.service';
import { toArray } from '@kit/shared/utils'; import { IMedipostPublicMessageDataParsed, IUuringElement } from '~/lib/services/medipost.types';
import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry } from '~/lib/services/analyses.service';
import {
createAnalysis,
createNoDataReceivedEntry,
createNoNewDataReceivedEntry,
createSyncFailEntry,
createSyncSuccessEntry,
getAnalyses,
} from '~/lib/services/analyses.service';
import {
AnalysisElement,
createAnalysisElement,
getAnalysisElements,
} from '~/lib/services/analysis-element.service';
import {
createAnalysisGroup,
getAnalysisGroups,
} from '~/lib/services/analysis-group.service';
import { createCodes } from '~/lib/services/codes.service';
import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost/medipost.types';
import { getLatestPublicMessageListItem } from '~/lib/services/medipost/medipostPublicMessage.service';
import { getLastCheckedDate } from '~/lib/services/sync-entries.service'; import { getLastCheckedDate } from '~/lib/services/sync-entries.service';
import { createAnalysisElement, getAnalysisElements } from '~/lib/services/analysis-element.service';
import { createCodes } from '~/lib/services/codes.service';
import { getLatestPublicMessageListItem } from '~/lib/services/medipost.service';
import type { ICode } from '~/lib/types/code'; import type { ICode } from '~/lib/types/code';
function toArray<T>(input?: T | T[] | null): T[] {
if (!input) return [];
return Array.isArray(input) ? input : [input];
}
const WRITE_XML_TO_FILE = false as boolean; const WRITE_XML_TO_FILE = false as boolean;
export default async function syncAnalysisGroups() { export default async function syncAnalysisGroups() {
@@ -68,8 +56,7 @@ export default async function syncAnalysisGroups() {
} }
const parser = new XMLParser({ ignoreAttributes: false }); const parser = new XMLParser({ ignoreAttributes: false });
const parsed: IMedipostPublicMessageDataParsed = const parsed: IMedipostPublicMessageDataParsed = parser.parse(publicMessageData);
parser.parse(publicMessageData);
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) { if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
throw new Error( throw new Error(
@@ -93,21 +80,15 @@ export default async function syncAnalysisGroups() {
} }
const codes: ICode[] = []; const codes: ICode[] = [];
const analysesToCreate: { analysisGroupId: number, analyses: IUuringElement[], analysisElementId: number }[] = [];
for (const analysisGroup of analysisGroups) { for (const analysisGroup of analysisGroups) {
const existingAnalysisGroup = existingAnalysisGroups?.find( let analysisGroupId: number | undefined;
({ original_id }) => original_id === analysisGroup.UuringuGruppId, const existingAnalysisGroup = existingAnalysisGroups?.find(({ original_id }) => original_id === analysisGroup.UuringuGruppId);
);
let groupExistingAnalysisElements: AnalysisElement[] = [];
let analysisGroupId: number;
if (existingAnalysisGroup) { if (existingAnalysisGroup) {
console.info( console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists`);
`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists, only creating new analysis elements`,
);
groupExistingAnalysisElements = await getAnalysisElements({
analysisGroupId: existingAnalysisGroup.id,
});
analysisGroupId = existingAnalysisGroup.id; analysisGroupId = existingAnalysisGroup.id;
} else { } else {
// SAVE ANALYSIS GROUP
analysisGroupId = await createAnalysisGroup({ analysisGroupId = await createAnalysisGroup({
id: analysisGroup.UuringuGruppId, id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi, name: analysisGroup.UuringuGruppNimi,
@@ -121,7 +102,7 @@ export default async function syncAnalysisGroups() {
hk_code_multiplier: kood.HkKoodiKordaja, hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient, coefficient: kood.Koefitsient,
price: kood.Hind, price: kood.Hind,
analysis_group_id: analysisGroupId, analysis_group_id: analysisGroupId!,
analysis_element_id: null, analysis_element_id: null,
analysis_id: null, analysis_id: null,
})), })),
@@ -131,87 +112,60 @@ export default async function syncAnalysisGroups() {
const analysisGroupItems = toArray(analysisGroup.Uuring); const analysisGroupItems = toArray(analysisGroup.Uuring);
for (const item of analysisGroupItems) { for (const item of analysisGroupItems) {
const analysisElement = item.UuringuElement!; const analysisElement = item.UuringuElement;
const isExistingAnalysisElement = groupExistingAnalysisElements.find(
({ analysis_id_original }) =>
analysis_id_original === analysisElement.UuringId,
);
if (isExistingAnalysisElement) {
console.info(
`Analysis element '${analysisElement.UuringNimi}' already exists`,
);
continue;
}
const insertedAnalysisElementId = await createAnalysisElement({ let insertedAnalysisElementId: number | undefined;
analysisElement: analysisElement!, const existingAnalysisElement = (await getAnalysisElements({ originalIds: [analysisElement.UuringId] }))?.[0];
analysisGroupId, if (existingAnalysisElement) {
materialGroups: toArray(item.MaterjalideGrupp), console.info(`Analysis element '${analysisElement.UuringNimi}' already exists`);
}); insertedAnalysisElementId = existingAnalysisElement.id;
} else {
if (Array.isArray(analysisElement.UuringuElement)) { insertedAnalysisElementId = await createAnalysisElement({
for (const nestedAnalysisElement of analysisElement.UuringuElement) { analysisElement,
await createAnalysisElement({ analysisGroupId: analysisGroupId!,
analysisElement: nestedAnalysisElement, materialGroups: toArray(item.MaterjalideGrupp),
analysisGroupId, });
materialGroups: toArray(item.MaterjalideGrupp), if (analysisElement.Kood) {
}); const analysisElementCodes = toArray(analysisElement.Kood);
codes.push(
...analysisElementCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: insertedAnalysisElementId!,
analysis_id: null,
})),
);
} }
} }
if (analysisElement.Kood) { const analyses = toArray(analysisElement.UuringuElement);
const analysisElementCodes = toArray(analysisElement.Kood); if (analyses?.length && insertedAnalysisElementId) {
analysesToCreate.push({ analysisGroupId: analysisGroupId!, analyses, analysisElementId: insertedAnalysisElementId });
}
}
}
for (const { analysisGroupId, analyses, analysisElementId } of analysesToCreate) {
for (const analysis of analyses) {
const insertedAnalysisId = await createAnalysis(analysis, analysisElementId);
if (analysis.Kood) {
const analysisCodes = toArray(analysis.Kood);
codes.push( codes.push(
...analysisElementCodes.map((kood) => ({ ...analysisCodes.map((kood) => ({
hk_code: kood.HkKood, hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja, hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient, coefficient: kood.Koefitsient,
price: kood.Hind, price: kood.Hind,
analysis_group_id: null, analysis_group_id: null,
analysis_element_id: insertedAnalysisElementId, analysis_element_id: null,
analysis_id: null, analysis_id: insertedAnalysisId,
})), })),
); );
} }
const analyses = analysisElement.UuringuElement;
if (analyses?.length) {
const existingAnalyses = await getAnalyses({
originalIds: analyses.map(({ UuringId }) => UuringId),
});
for (const analysis of analyses) {
const isExistingAnalysis = existingAnalyses.find(
({ analysis_id_original }) =>
analysis_id_original === analysis.UuringId,
);
if (isExistingAnalysis) {
console.info(`Analysis '${analysis.UuringNimi}' already exists`);
continue;
}
const insertedAnalysisId = await createAnalysis(
analysis,
analysisGroupId,
);
if (analysis.Kood) {
const analysisCodes = toArray(analysis.Kood);
codes.push(
...analysisCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: null,
analysis_id: insertedAnalysisId,
})),
);
}
}
}
} }
} }
@@ -222,17 +176,11 @@ export default async function syncAnalysisGroups() {
await createSyncSuccessEntry(); await createSyncSuccessEntry();
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
await createSyncFailEntry( await createSyncFailEntry(JSON.stringify({
JSON.stringify( message: errorMessage,
{ stack: e instanceof Error ? e.stack : undefined,
message: errorMessage, name: e instanceof Error ? e.name : 'Unknown',
stack: e instanceof Error ? e.stack : undefined, }, null, 2));
name: e instanceof Error ? e.name : 'Unknown',
},
null,
2,
),
);
console.error('Sync failed:', e); console.error('Sync failed:', e);
throw new Error( throw new Error(
`Failed to sync public message data, error: ${errorMessage}`, `Failed to sync public message data, error: ${errorMessage}`,

View File

@@ -1,4 +1,4 @@
import { readPrivateMessageResponse } from '~/lib/services/medipost/medipostPrivateMessage.service'; import { readPrivateMessageResponse } from "~/lib/services/medipost.service";
type ProcessedMessage = { type ProcessedMessage = {
messageId: string; messageId: string;
@@ -6,18 +6,17 @@ type ProcessedMessage = {
hasPartialAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean;
hasFullAnalysisResponse: boolean; hasFullAnalysisResponse: boolean;
medusaOrderId: string | undefined; medusaOrderId: string | undefined;
analysisOrderId: number | undefined;
}; };
type GroupedResults = { type GroupedResults = {
processed: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[]; processed: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[]; waitingForResults: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
}; };
export default async function syncAnalysisResults() { export default async function syncAnalysisResults() {
console.info('Syncing analysis results'); console.info("Syncing analysis results");
const processedMessages: ProcessedMessage[] = []; let processedMessages: ProcessedMessage[] = [];
const excludedMessageIds: string[] = []; const excludedMessageIds: string[] = [];
while (true) { while (true) {
const result = await readPrivateMessageResponse({ excludedMessageIds }); const result = await readPrivateMessageResponse({ excludedMessageIds });
@@ -26,7 +25,7 @@ export default async function syncAnalysisResults() {
} }
if (!result.messageId) { if (!result.messageId) {
console.info('No more messages to process'); console.info("No more messages to process");
break; break;
} }
@@ -38,14 +37,14 @@ export default async function syncAnalysisResults() {
} }
const groupedResults = processedMessages.reduce((acc, result) => { const groupedResults = processedMessages.reduce((acc, result) => {
if (result.analysisOrderId) { if (result.medusaOrderId) {
if (result.hasAnalysisResponse) { if (result.hasAnalysisResponse) {
if (!acc.processed) { if (!acc.processed) {
acc.processed = []; acc.processed = [];
} }
acc.processed.push({ acc.processed.push({
messageId: result.messageId, messageId: result.messageId,
analysisOrderId: result.analysisOrderId, medusaOrderId: result.medusaOrderId,
}); });
} else { } else {
if (!acc.waitingForResults) { if (!acc.waitingForResults) {
@@ -53,13 +52,11 @@ export default async function syncAnalysisResults() {
} }
acc.waitingForResults.push({ acc.waitingForResults.push({
messageId: result.messageId, messageId: result.messageId,
analysisOrderId: result.analysisOrderId, medusaOrderId: result.medusaOrderId,
}); });
} }
} }
return acc; return acc;
}, {} as GroupedResults); }, {} as GroupedResults);
console.info( console.info(`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults)}`);
`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults)}`,
);
} }

View File

@@ -2,45 +2,10 @@ import axios from 'axios';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { logSyncResult } from '~/lib/services/audit.service'; import type { ISearchLoadResponse } from '~/lib/types/connected-online';
import { SyncStatus } from '~/lib/types/audit';
import type {
ISearchLoadResponse,
P_JobTitleTranslation,
} from '~/lib/types/connected-online';
function createTranslationMap(translations: P_JobTitleTranslation[]) {
const result: Map<
number,
Map<number, { textEN: string; textRU: string; textET: string }>
> = new Map();
for (const translation of translations) {
const { ClinicID, TextET, TextEN, TextRU, SyncID } = translation;
if (!result.has(ClinicID)) {
result.set(ClinicID, new Map());
}
result.get(ClinicID)!.set(SyncID, {
textET: TextET,
textEN: TextEN,
textRU: TextRU,
});
}
return result;
}
function getSpokenLanguages(spokenLanguages?: string) {
if (!spokenLanguages || !spokenLanguages.length) return [];
return spokenLanguages.split(',');
}
export default async function syncConnectedOnline() { export default async function syncConnectedOnline() {
const isProd = !['test', 'localhost'].some((pathString) => const isProd = process.env.NODE_ENV === 'production';
process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString),
);
const baseUrl = process.env.CONNECTED_ONLINE_URL; const baseUrl = process.env.CONNECTED_ONLINE_URL;
@@ -51,19 +16,14 @@ export default async function syncConnectedOnline() {
const supabase = getSupabaseServerAdminClient(); const supabase = getSupabaseServerAdminClient();
try { try {
const searchLoadResponse = await axios.post<{ d: string }>( const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, {
`${baseUrl}/Search_Load`, headers: {
{ 'Content-Type': 'application/json; charset=utf-8',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
param: "{'Value':'|et|-1'}", // get all available services in Estonian
}, },
); param: "{'Value':'|et|-1'}", // get all available services in Estonian
});
const responseData: ISearchLoadResponse = JSON.parse( const responseData: ISearchLoadResponse = JSON.parse(response.data.d);
searchLoadResponse.data.d,
);
if (responseData?.ErrorCode !== 0) { if (responseData?.ErrorCode !== 0) {
throw new Error('Failed to get Connected Online data'); throw new Error('Failed to get Connected Online data');
@@ -83,23 +43,18 @@ export default async function syncConnectedOnline() {
let clinics; let clinics;
let services; let services;
let serviceProviders;
let jobTitleTranslations;
// Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment // Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment
const isDemoClinic = (clinicId: number) => if (isProd) {
isProd ? clinicId !== 2 : clinicId === 2; clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID !== 2);
clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID)); services = responseData.Data.T_Service.filter(
services = responseData.Data.T_Service.filter(({ ClinicID }) => (service) => service.ClinicID !== 2,
isDemoClinic(ClinicID), );
); } else {
serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID === 2);
isDemoClinic(ClinicID), services = responseData.Data.T_Service.filter(
); (service) => service.ClinicID === 2,
jobTitleTranslations = createTranslationMap( );
responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => }
isDemoClinic(ClinicID),
),
);
const mappedClinics = clinics.map((clinic) => { const mappedClinics = clinics.map((clinic) => {
return { return {
@@ -109,8 +64,6 @@ export default async function syncConnectedOnline() {
name: clinic.Name, name: clinic.Name,
personal_code_required: !!clinic.PersonalCodeRequired, personal_code_required: !!clinic.PersonalCodeRequired,
phone_number: clinic.Phone || null, phone_number: clinic.Phone || null,
key: clinic.Key,
address: clinic.Address,
}; };
}); });
@@ -118,7 +71,7 @@ export default async function syncConnectedOnline() {
return { return {
id: service.ID, id: service.ID,
clinic_id: service.ClinicID, clinic_id: service.ClinicID,
sync_id: Number(service.SyncID), sync_id: service.SyncID,
name: service.Name, name: service.Name,
description: service.Description || null, description: service.Description || null,
price: service.Price, price: service.Price,
@@ -134,133 +87,45 @@ export default async function syncConnectedOnline() {
}; };
}); });
const mappedServiceProviders = serviceProviders.map((serviceProvider) => {
const jobTitleTranslation = serviceProvider.JobTitleID
? jobTitleTranslations
.get(serviceProvider.ClinicID)
?.get(serviceProvider.JobTitleID)
: null;
return {
id: serviceProvider.ID,
prefix: serviceProvider.Prefix,
name: serviceProvider.Name,
spoken_languages: getSpokenLanguages(serviceProvider.SpokenLanguages),
job_title_et: jobTitleTranslation?.textET,
job_title_en: jobTitleTranslation?.textEN,
job_title_ru: jobTitleTranslation?.textRU,
job_title_id: serviceProvider.JobTitleID,
is_deleted: !!serviceProvider.Deleted,
clinic_id: serviceProvider.ClinicID,
};
});
const { error: providersError } = await supabase const { error: providersError } = await supabase
.schema('medreport') .schema('medreport')
.from('connected_online_providers') .from('connected_online_providers')
.upsert(mappedClinics); .upsert(mappedClinics);
if (providersError) {
return logSyncResult({
operation: 'CONNECTED_ONLINE_SYNC',
comment:
'Error saving connected online providers: ' +
JSON.stringify(providersError),
status: SyncStatus.Fail,
changed_by_role: 'service_role',
});
}
const { error: servicesError } = await supabase const { error: servicesError } = await supabase
.schema('medreport') .schema('medreport')
.from('connected_online_services') .from('connected_online_services')
.upsert(mappedServices, { .upsert(mappedServices, { onConflict: 'id', ignoreDuplicates: false });
onConflict: 'id',
ignoreDuplicates: false,
});
if (servicesError) { if (providersError || servicesError) {
return logSyncResult({ return supabase
operation: 'CONNECTED_ONLINE_SYNC', .schema('audit')
comment: .from('sync_entries')
'Error saving connected online services: ' + .insert({
JSON.stringify(servicesError), operation: 'CONNECTED_ONLINE_SYNC',
status: SyncStatus.Fail, comment: providersError
changed_by_role: 'service_role', ? 'Error saving providers: ' + JSON.stringify(providersError)
}); : 'Error saving services: ' + JSON.stringify(servicesError),
status: 'FAIL',
changed_by_role: 'service_role',
});
} }
const { error: serviceProvidersError } = await supabase await supabase.schema('audit').from('sync_entries').insert({
.schema('medreport')
.from('connected_online_service_providers')
.upsert(mappedServiceProviders, {
onConflict: 'id',
ignoreDuplicates: false,
});
if (serviceProvidersError) {
return logSyncResult({
operation: 'CONNECTED_ONLINE_SYNC',
comment:
'Error saving service providers: ' +
JSON.stringify(serviceProvidersError),
status: SyncStatus.Fail,
changed_by_role: 'service_role',
});
}
for (const mappedClinic of mappedClinics) {
const defaultLoadResponse = await axios.post<{ d: string }>(
`${baseUrl}/Default_Load`,
{
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
param: `{'Value':'${mappedClinic.key}|et'}`,
},
);
const defaultLoadResponseData = JSON.parse(defaultLoadResponse.data.d);
if (defaultLoadResponseData?.ErrorCode !== 0) {
throw new Error('Failed to get Connected Online location data');
}
const clinicLocations: {
SyncID: number;
Address: string;
Name: string;
}[] = defaultLoadResponseData.Data.T_SelectableLocation;
if (clinicLocations?.length) {
const mappedLocations = clinicLocations.map(
({ SyncID, Address, Name }) => ({
address: Address,
clinic_id: mappedClinic.id,
sync_id: SyncID,
name: Name,
}),
);
await supabase
.schema('medreport')
.from('connected_online_locations')
.insert(mappedLocations)
.throwOnError();
}
}
await logSyncResult({
operation: 'CONNECTED_ONLINE_SYNC', operation: 'CONNECTED_ONLINE_SYNC',
status: SyncStatus.Success, status: 'SUCCESS',
changed_by_role: 'service_role', changed_by_role: 'service_role',
}); });
} catch (e) { } catch (e) {
await logSyncResult({ await supabase
operation: 'CONNECTED_ONLINE_SYNC', .schema('audit')
status: SyncStatus.Fail, .from('sync_entries')
comment: JSON.stringify(e), .insert({
changed_by_role: 'service_role', operation: 'CONNECTED_ONLINE_SYNC',
}); status: 'FAIL',
comment: JSON.stringify(e),
changed_by_role: 'service_role',
});
throw new Error( throw new Error(
`Failed to sync Connected Online data, error: ${JSON.stringify(e)}`, `Failed to sync Connected Online data, error: ${JSON.stringify(e)}`,
); );

View File

@@ -1,4 +1,4 @@
import { NextRequest } from 'next/server'; import { NextRequest } from "next/server";
export default function validateApiKey(request: NextRequest) { export default function validateApiKey(request: NextRequest) {
const envApiKey = process.env.JOBS_API_TOKEN; const envApiKey = process.env.JOBS_API_TOKEN;

View File

@@ -1,13 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import { retrieveOrder } from '@lib/data/orders'; import validateApiKey from "../handler/validate-api-key";
import { getOrderedAnalysisIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
import { getMedipostDispatchTries } from '~/lib/services/audit.service'; import { retrieveOrder } from "@lib/data/orders";
import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; import { getMedipostDispatchTries } from "~/lib/services/audit.service";
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
import loadEnv from '../handler/load-env';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
@@ -22,34 +18,23 @@ export const POST = async (request: NextRequest) => {
const tries = await getMedipostDispatchTries(medusaOrderId); const tries = await getMedipostDispatchTries(medusaOrderId);
if (tries >= 3) { if (tries >= 3) {
return NextResponse.json( return NextResponse.json({
{ message: 'Order has been retried too many times',
message: 'Order has been retried too many times', }, { status: 400 });
},
{ status: 400 },
);
} }
try { try {
const medusaOrder = await retrieveOrder(medusaOrderId); const medusaOrder = await retrieveOrder(medusaOrderId);
const orderedAnalysisElements = await getOrderedAnalysisIds({ const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
medusaOrder,
});
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
console.info('Successfully sent order to medipost'); console.info("Successfully sent order to medipost");
return NextResponse.json( return NextResponse.json({
{ message: 'Successfully sent order to medipost',
message: 'Successfully sent order to medipost', }, { status: 200 });
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error('Error sending order to medipost', e); console.error("Error sending order to medipost", e);
return NextResponse.json( return NextResponse.json({
{ message: 'Failed to send order to medipost',
message: 'Failed to send order to medipost', }, { status: 500 });
},
{ status: 500 },
);
} }
}; };

View File

@@ -4,7 +4,6 @@ import {
NotificationAction, NotificationAction,
createNotificationLog, createNotificationLog,
} from '~/lib/services/audit/notificationEntries.service'; } from '~/lib/services/audit/notificationEntries.service';
import loadEnv from '../handler/load-env'; import loadEnv from '../handler/load-env';
import sendOpenJobsEmails from '../handler/send-open-jobs-emails'; import sendOpenJobsEmails from '../handler/send-open-jobs-emails';
import validateApiKey from '../handler/validate-api-key'; import validateApiKey from '../handler/validate-api-key';
@@ -24,7 +23,7 @@ export const POST = async (request: NextRequest) => {
'Successfully sent out open job notification emails to doctors.', 'Successfully sent out open job notification emails to doctors.',
); );
await createNotificationLog({ await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS, action: NotificationAction.NEW_JOBS_ALERT,
status: 'SUCCESS', status: 'SUCCESS',
}); });
return NextResponse.json( return NextResponse.json(
@@ -40,7 +39,7 @@ export const POST = async (request: NextRequest) => {
e, e,
); );
await createNotificationLog({ await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS, action: NotificationAction.NEW_JOBS_ALERT,
status: 'FAIL', status: 'FAIL',
comment: e?.message, comment: e?.message,
}); });

View File

@@ -1,8 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import loadEnv from '../handler/load-env'; import validateApiKey from "../handler/validate-api-key";
import syncAnalysisGroupsStore from '../handler/sync-analysis-groups-store'; import syncAnalysisGroupsStore from "../handler/sync-analysis-groups-store";
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
@@ -15,20 +14,14 @@ export const POST = async (request: NextRequest) => {
try { try {
await syncAnalysisGroupsStore(); await syncAnalysisGroupsStore();
console.info('Successfully synced analysis groups store'); console.info("Successfully synced analysis groups store");
return NextResponse.json( return NextResponse.json({
{ message: 'Successfully synced analysis groups store',
message: 'Successfully synced analysis groups store', }, { status: 200 });
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error('Error syncing analysis groups store', e); console.error("Error syncing analysis groups store", e);
return NextResponse.json( return NextResponse.json({
{ message: 'Failed to sync analysis groups store',
message: 'Failed to sync analysis groups store', }, { status: 500 });
},
{ status: 500 },
);
} }
}; };

View File

@@ -1,8 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import syncAnalysisGroups from "../handler/sync-analysis-groups";
import loadEnv from '../handler/load-env'; import loadEnv from "../handler/load-env";
import syncAnalysisGroups from '../handler/sync-analysis-groups'; import validateApiKey from "../handler/validate-api-key";
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
@@ -15,20 +14,15 @@ export const POST = async (request: NextRequest) => {
try { try {
await syncAnalysisGroups(); await syncAnalysisGroups();
console.info('Successfully synced analysis groups'); console.info("Successfully synced analysis groups");
return NextResponse.json( return NextResponse.json({
{ message: 'Successfully synced analysis groups',
message: 'Successfully synced analysis groups', }, { status: 200 });
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error('Error syncing analysis groups', e); console.error("Error syncing analysis groups", e);
return NextResponse.json( return NextResponse.json({
{ message: 'Failed to sync analysis groups',
message: 'Failed to sync analysis groups', error: e instanceof Error ? JSON.stringify(e, undefined, 2) : 'Unknown error',
}, }, { status: 500 });
{ status: 500 },
);
} }
}; };

View File

@@ -1,8 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import loadEnv from '../handler/load-env'; import validateApiKey from "../handler/validate-api-key";
import syncAnalysisResults from '../handler/sync-analysis-results'; import syncAnalysisResults from "../handler/sync-analysis-results";
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
@@ -15,20 +14,14 @@ export const POST = async (request: NextRequest) => {
try { try {
await syncAnalysisResults(); await syncAnalysisResults();
console.info('Successfully synced analysis results'); console.info("Successfully synced analysis results");
return NextResponse.json( return NextResponse.json({
{ message: 'Successfully synced analysis results',
message: 'Successfully synced analysis results', }, { status: 200 });
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error('Error syncing analysis results', e); console.error("Error syncing analysis results", e);
return NextResponse.json( return NextResponse.json({
{ message: 'Failed to sync analysis results',
message: 'Failed to sync analysis results', }, { status: 500 });
},
{ status: 500 },
);
} }
}; };

View File

@@ -1,8 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import loadEnv from '../handler/load-env'; import validateApiKey from "../handler/validate-api-key";
import syncConnectedOnline from '../handler/sync-connected-online'; import syncConnectedOnline from "../handler/sync-connected-online";
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
@@ -15,20 +14,14 @@ export const POST = async (request: NextRequest) => {
try { try {
await syncConnectedOnline(); await syncConnectedOnline();
console.info('Successfully synced connected-online'); console.info("Successfully synced connected-online");
return NextResponse.json( return NextResponse.json({
{ message: 'Successfully synced connected-online',
message: 'Successfully synced connected-online', }, { status: 200 });
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error('Error syncing connected-online', e); console.error("Error syncing connected-online", e);
return NextResponse.json( return NextResponse.json({
{ message: 'Failed to sync connected-online',
message: 'Failed to sync connected-online', }, { status: 500 });
},
{ status: 500 },
);
} }
}; };

View File

@@ -1,17 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from "next/server";
import { getAnalysisOrdersAdmin } from "~/lib/services/order.service";
import { retrieveOrder } from '@lib/data'; import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from '~/lib/services/account.service'; import { getAccountAdmin } from "~/lib/services/account.service";
import { import { getOrderedAnalysisIds } from "~/lib/services/medipost.service";
composeOrderTestResponseXML, import loadEnv from "../handler/load-env";
sendPrivateMessageTestResponse, import validateApiKey from "../handler/validate-api-key";
} from '~/lib/services/medipost/medipostTest.service';
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
import { getAnalysisOrdersAdmin } from '~/lib/services/order.service';
import loadEnv from '../handler/load-env';
import validateApiKey from '../handler/validate-api-key';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
loadEnv(); loadEnv();
@@ -22,27 +16,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
const analysisOrders = await getAnalysisOrdersAdmin({ const analysisOrders = await getAnalysisOrdersAdmin({ orderStatus: 'PROCESSING' });
orderStatus: 'PROCESSING',
});
console.error( console.error(`Sending test responses for ${analysisOrders.length} analysis orders`);
`Sending test responses for ${analysisOrders.length} analysis orders`,
);
for (const medreportOrder of analysisOrders) { for (const medreportOrder of analysisOrders) {
const medusaOrderId = medreportOrder.medusa_order_id; const medusaOrderId = medreportOrder.medusa_order_id;
const medusaOrder = await retrieveOrder(medusaOrderId); const medusaOrder = await retrieveOrder(medusaOrderId)
const account = await getAccountAdmin({ const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
primaryOwnerUserId: medreportOrder.user_id, const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
});
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({
medusaOrder,
});
console.info( console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`,
);
const idsToSend = orderedAnalysisElementsIds; const idsToSend = orderedAnalysisElementsIds;
const messageXml = await composeOrderTestResponseXML({ const messageXml = await composeOrderTestResponseXML({
person: { person: {
@@ -51,20 +35,16 @@ export async function POST(request: NextRequest) {
lastName: account.last_name ?? '', lastName: account.last_name ?? '',
phone: account.phone ?? '', phone: account.phone ?? '',
}, },
orderedAnalysisElementsIds: idsToSend orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
.map(({ analysisElementId }) => analysisElementId) orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
.filter(Boolean) as number[], orderId: medusaOrderId,
orderedAnalysesIds: idsToSend
.map(({ analysisId }) => analysisId)
.filter(Boolean) as number[],
orderId: medreportOrder.id,
orderCreatedAt: new Date(medreportOrder.created_at), orderCreatedAt: new Date(medreportOrder.created_at),
}); });
try { try {
await sendPrivateMessageTestResponse({ messageXml }); await sendPrivateMessageTestResponse({ messageXml });
} catch (error) { } catch (error) {
console.error('Error sending private message test response: ', error); console.error("Error sending private message test response: ", error);
} }
} }

View File

@@ -1,5 +1,4 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { z } from 'zod'; import { z } from 'zod';
@@ -95,4 +94,4 @@ export const POST = enhanceRouteHandler(
{ {
auth: false, auth: false,
}, },
); );

View File

@@ -1,15 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
import { getOrder } from "~/lib/services/order.service";
import { retrieveOrder } from '@lib/data'; import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from '~/lib/services/account.service'; import { getAccountAdmin } from "~/lib/services/account.service";
import { upsertMedipostActionLog } from '~/lib/services/medipost/medipostMessageBase.service'; import { createMedipostActionLog, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
import {
composeOrderTestResponseXML,
sendPrivateMessageTestResponse,
} from '~/lib/services/medipost/medipostTest.service';
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
import { getAnalysisOrder } from '~/lib/services/order.service';
export async function POST(request: Request) { export async function POST(request: Request) {
// const isDev = process.env.NODE_ENV === 'development'; // const isDev = process.env.NODE_ENV === 'development';
@@ -19,19 +13,13 @@ export async function POST(request: Request) {
const { medusaOrderId } = await request.json(); const { medusaOrderId } = await request.json();
const medusaOrder = await retrieveOrder(medusaOrderId); const medusaOrder = await retrieveOrder(medusaOrderId)
const analysisOrder = await getAnalysisOrder({ medusaOrderId }); const medreportOrder = await getOrder({ medusaOrderId });
const account = await getAccountAdmin({ const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
primaryOwnerUserId: analysisOrder.user_id, const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
});
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({
medusaOrder,
});
console.info( console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`,
);
const messageXml = await composeOrderTestResponseXML({ const messageXml = await composeOrderTestResponseXML({
person: { person: {
idCode: account.personal_code!, idCode: account.personal_code!,
@@ -39,25 +27,21 @@ export async function POST(request: Request) {
lastName: account.last_name ?? '', lastName: account.last_name ?? '',
phone: account.phone ?? '', phone: account.phone ?? '',
}, },
orderedAnalysisElementsIds: orderedAnalysisElementsIds orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
.map(({ analysisElementId }) => analysisElementId) orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
.filter(Boolean) as number[], orderId: medusaOrderId,
orderedAnalysesIds: orderedAnalysisElementsIds orderCreatedAt: new Date(medreportOrder.created_at),
.map(({ analysisId }) => analysisId)
.filter(Boolean) as number[],
orderId: analysisOrder.id,
orderCreatedAt: new Date(analysisOrder.created_at),
}); });
try { try {
await upsertMedipostActionLog({ await createMedipostActionLog({
action: 'send_fake_analysis_results_to_medipost', action: 'send_fake_analysis_results_to_medipost',
xml: messageXml, xml: messageXml,
medusaOrderId, medusaOrderId,
}); });
await sendPrivateMessageTestResponse({ messageXml }); await sendPrivateMessageTestResponse({ messageXml });
} catch (error) { } catch (error) {
console.error('Error sending private message test response: ', error); console.error("Error sending private message test response: ", error);
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });

View File

@@ -3,13 +3,15 @@ import Link from 'next/link';
import type { AuthError } from '@supabase/supabase-js'; import type { AuthError } from '@supabase/supabase-js';
import { ResendAuthLinkForm } from '@kit/auth/resend-email-link'; import { ResendAuthLinkForm } from '@kit/auth/resend-email-link';
import { pathsConfig } from '@kit/shared/config';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { pathsConfig } from '@kit/shared/config';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
interface AuthCallbackErrorPageProps { interface AuthCallbackErrorPageProps {
searchParams: Promise<{ searchParams: Promise<{
error: string; error: string;

View File

@@ -1,4 +1,5 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,20 +1,17 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { createAccountsApi } from '@/packages/features/accounts/src/server/api'; import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
createAuthCallbackService,
getErrorURLParameters,
} from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const ERROR_PATH = '/auth/callback/error'; const ERROR_PATH = '/auth/callback/error';
const redirectOnError = (searchParams?: string) => { const redirectOnError = (searchParams?: string) => {
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`); return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
}; }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -47,17 +44,19 @@ export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient()); const service = createAuthCallbackService(getSupabaseServerClient());
const oauthResult = await service.exchangeCodeForSession(authCode); const oauthResult = await service.exchangeCodeForSession(authCode);
if (!('isSuccess' in oauthResult)) { if (!("isSuccess" in oauthResult)) {
return redirectOnError(oauthResult.searchParams); return redirectOnError(oauthResult.searchParams);
} }
const api = createAccountsApi(getSupabaseServerClient()); const api = createAccountsApi(getSupabaseServerClient());
const account = await api.getPersonalAccountByUserId(oauthResult.user.id); const account = await api.getPersonalAccountByUserId(
oauthResult.user.id,
);
if (!account.email || !account.name || !account.last_name) { if (!account.email || !account.name || !account.last_name) {
return redirect(pathsConfig.auth.updateAccount); return redirect(pathsConfig.auth.updateAccount);
} }
return redirect(redirectPath); return redirect(redirectPath);
} }

View File

@@ -1,4 +1,5 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,9 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { pathsConfig } from '@kit/shared/config';
import { createAuthCallbackService } from '@kit/supabase/auth'; import { createAuthCallbackService } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient()); const service = createAuthCallbackService(getSupabaseServerClient());

View File

@@ -2,11 +2,11 @@
import React from 'react'; import React from 'react';
import { pathsConfig } from '@kit/shared/config';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data'; import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { SuccessNotification } from '@kit/notifications/components'; import { SuccessNotification } from '@kit/notifications/components';
import { pathsConfig } from '@kit/shared/config';
const MembershipConfirmationNotification: React.FC<{ const MembershipConfirmationNotification: React.FC<{
userId: string; userId: string;

View File

@@ -1,5 +1,6 @@
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) { async function SiteLayout(props: React.PropsWithChildren) {
return ( return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}> <div className={'flex min-h-[100vh] flex-col items-center justify-center'}>

View File

@@ -1,4 +1,5 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,14 +1,16 @@
import Link from 'next/link'; import Link from 'next/link';
import { PasswordResetRequestContainer } from '@kit/auth/password-reset'; import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { pathsConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -1,7 +1,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { Providers, SignInMethodsContainer } from '@kit/auth/sign-in'; import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { pathsConfig } from '@kit/shared/config'; import { authConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
@@ -9,11 +9,9 @@ import { Trans } from '@kit/ui/trans';
export default function PasswordOption({ export default function PasswordOption({
inviteToken, inviteToken,
returnPath, returnPath,
providers,
}: { }: {
inviteToken?: string; inviteToken?: string;
returnPath?: string; returnPath?: string;
providers: Providers;
}) { }) {
const signUpPath = const signUpPath =
pathsConfig.auth.signUp + pathsConfig.auth.signUp +
@@ -41,7 +39,7 @@ export default function PasswordOption({
<SignInMethodsContainer <SignInMethodsContainer
inviteToken={inviteToken} inviteToken={inviteToken}
paths={paths} paths={paths}
providers={providers} providers={authConfig.providers}
/> />
<div className={'flex justify-center'}> <div className={'flex justify-center'}>

View File

@@ -1,26 +1,25 @@
'use client'; 'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Loading from '@/app/home/loading'; import Loading from '@/app/home/loading';
import { useEffect } from 'react';
import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client'; import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client';
import { useRouter } from 'next/navigation';
export function SignInPageClientRedirect() { export function SignInPageClientRedirect() {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
async function signIn() { async function signIn() {
const { data, error } = const { data, error } = await getSupabaseBrowserClient()
await getSupabaseBrowserClient().auth.signInWithOAuth({ .auth
.signInWithOAuth({
provider: 'keycloak', provider: 'keycloak',
options: { options: {
redirectTo: `${window.location.origin}/auth/callback`, redirectTo: `${window.location.origin}/auth/callback`,
queryParams: { queryParams: {
prompt: 'login', prompt: 'login',
}, },
}, }
}); });
if (error) { if (error) {

View File

@@ -1,4 +1,5 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,10 +1,9 @@
import { getServerAuthConfig, pathsConfig } from '@kit/shared/config'; import { pathsConfig, authConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import PasswordOption from './components/PasswordOption';
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect'; import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
import PasswordOption from './components/PasswordOption';
interface SignInPageProps { interface SignInPageProps {
searchParams: Promise<{ searchParams: Promise<{
@@ -25,23 +24,11 @@ async function SignInPage({ searchParams }: SignInPageProps) {
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } = const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
await searchParams; await searchParams;
const authConfig = await getServerAuthConfig();
if (authConfig.providers.password) { if (authConfig.providers.password) {
return ( return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
<PasswordOption
inviteToken={inviteToken}
returnPath={returnPath}
providers={authConfig.providers}
/>
);
} }
if (authConfig.providers.oAuth.includes('keycloak')) { return <SignInPageClientRedirect />;
return <SignInPageClientRedirect />;
}
return null;
} }
export default withI18n(SignInPage); export default withI18n(SignInPage);

View File

@@ -1,4 +1,5 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -2,12 +2,13 @@ import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { SignUpMethodsContainer } from '@kit/auth/sign-up'; import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { getServerAuthConfig, pathsConfig } from '@kit/shared/config'; import { authConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
@@ -37,8 +38,6 @@ async function SignUpPage({ searchParams }: Props) {
pathsConfig.auth.signIn + pathsConfig.auth.signIn +
(inviteToken ? `?invite_token=${inviteToken}` : ''); (inviteToken ? `?invite_token=${inviteToken}` : '');
const authConfig = await getServerAuthConfig();
if (!authConfig.providers.password) { if (!authConfig.providers.password) {
return redirect('/'); return redirect('/');
} }
@@ -56,7 +55,8 @@ async function SignUpPage({ searchParams }: Props) {
</div> </div>
<SignUpMethodsContainer <SignUpMethodsContainer
authConfig={authConfig} providers={authConfig.providers}
displayTermsCheckbox={authConfig.displayTermsCheckbox}
inviteToken={inviteToken} inviteToken={inviteToken}
paths={paths} paths={paths}
/> />

View File

@@ -1,14 +1,10 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { pathsConfig } from '@/packages/shared/src/config';
import { ExternalLink } from '@/public/assets/external-link'; import { ExternalLink } from '@/public/assets/external-link';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox'; import { Checkbox } from '@kit/ui/checkbox';
@@ -21,80 +17,46 @@ import {
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchemaClient } from '../_lib/schemas/update-account.schema'; import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
import { onUpdateAccount } from '../_lib/server/update-account'; import { onUpdateAccount } from '../_lib/server/update-account';
import { z } from 'zod';
type UpdateAccountFormValues = z.infer< type UpdateAccountFormValues = z.infer<typeof UpdateAccountSchema>;
ReturnType<typeof UpdateAccountSchemaClient>
>;
export function UpdateAccountForm({ export function UpdateAccountForm({
defaultValues, defaultValues,
isEmailUser,
}: { }: {
defaultValues: UpdateAccountFormValues; defaultValues: UpdateAccountFormValues,
isEmailUser: boolean;
}) { }) {
const router = useRouter();
const { t } = useTranslation('account');
const form = useForm({ const form = useForm({
resolver: zodResolver(UpdateAccountSchemaClient({ isEmailUser })), resolver: zodResolver(UpdateAccountSchema),
mode: 'onChange', mode: 'onChange',
defaultValues, defaultValues,
}); });
const { firstName, lastName, personalCode, email, userConsent } = const { firstName, lastName, personalCode, email, weight, height, userConsent } = defaultValues;
defaultValues;
const defaultValues_weight =
'weight' in defaultValues ? defaultValues.weight : null;
const defaultValues_height =
'height' in defaultValues ? defaultValues.height : null;
const hasFirstName = !!firstName; const hasFirstName = !!firstName;
const hasLastName = !!lastName; const hasLastName = !!lastName;
const hasPersonalCode = !!personalCode; const hasPersonalCode = !!personalCode;
const hasEmail = !!email; const hasEmail = !!email;
const hasWeight = !!weight;
const hasHeight = !!height;
const hasUserConsent = !!userConsent;
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) => { const onUpdateAccountOptions = async (values: UpdateAccountFormValues) =>
const loading = toast.loading(t('updateAccount.updateAccountLoading')); onUpdateAccount({
try { ...values,
const response = await onUpdateAccount({ ...(hasFirstName && { firstName }),
firstName: values.firstName || firstName, ...(hasLastName && { lastName }),
lastName: values.lastName || lastName, ...(hasPersonalCode && { personalCode }),
personalCode: values.personalCode || personalCode, ...(hasEmail && { email }),
email: values.email || email, ...(hasWeight && { weight: values.weight ?? weight }),
phone: values.phone, ...(hasHeight && { height: values.height ?? height }),
weight: ((('weight' in values && values.weight) ?? ...(hasUserConsent && { userConsent: values.userConsent ?? userConsent }),
defaultValues_weight) || });
null) as number,
height: ((('height' in values && values.height) ??
defaultValues_height) ||
null) as number,
userConsent: values.userConsent ?? userConsent,
city: values.city,
});
if (!response) {
throw new Error('Failed to update account');
}
toast.dismiss(loading);
toast.success(t('updateAccount.updateAccountSuccess'));
if (response.hasUnseenMembershipConfirmation) {
router.push(pathsConfig.auth.membershipConfirmation);
} else {
router.push(pathsConfig.app.selectPackage);
}
} catch (error) {
console.info('promiseresult error', error);
toast.error(t('updateAccount.updateAccountError'));
toast.dismiss(loading);
}
};
return ( return (
<Form {...form}> <Form {...form}>
@@ -104,14 +66,14 @@ export function UpdateAccountForm({
> >
<FormField <FormField
name="firstName" name="firstName"
disabled={hasFirstName && !isEmailUser} disabled={hasFirstName}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:firstName'} /> <Trans i18nKey={'common:formField:firstName'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} autoFocus={!hasFirstName} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -120,14 +82,14 @@ export function UpdateAccountForm({
<FormField <FormField
name="lastName" name="lastName"
disabled={hasLastName && !isEmailUser} disabled={hasLastName}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:lastName'} /> <Trans i18nKey={'common:formField:lastName'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} autoFocus={hasFirstName && !hasLastName} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -136,7 +98,7 @@ export function UpdateAccountForm({
<FormField <FormField
name="personalCode" name="personalCode"
disabled={hasPersonalCode && !isEmailUser} disabled={hasPersonalCode}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
@@ -181,80 +143,72 @@ export function UpdateAccountForm({
)} )}
/> />
{!isEmailUser && ( <FormField
<> name="city"
<FormField render={({ field }) => (
name="city" <FormItem>
render={({ field }) => ( <FormLabel>
<FormItem> <Trans i18nKey={'common:formField:city'} />
<FormLabel> </FormLabel>
<Trans i18nKey={'common:formField:city'} /> <FormControl>
</FormLabel> <Input {...field} />
<FormControl> </FormControl>
<Input {...field} /> <FormMessage />
</FormControl> </FormItem>
<FormMessage /> )}
</FormItem> />
)}
/>
<div className="flex flex-row justify-between gap-4"> <div className="flex flex-row justify-between gap-4">
<FormField <FormField
name="weight" name="weight"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1 basis-0"> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:weight'} /> <Trans i18nKey={'common:formField:weight'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
placeholder="kg" placeholder="kg"
{...field} {...field}
value={field.value ?? ''} value={field.value ?? ''}
onChange={(e) => onChange={(e) =>
field.onChange( field.onChange(
e.target.value === '' e.target.value === '' ? null : Number(e.target.value),
? null )
: Number(e.target.value), }
) />
} </FormControl>
/> <FormMessage />
</FormControl> </FormItem>
<FormMessage /> )}
</FormItem> />
)}
/>
<FormField <FormField
name="height" name="height"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1 basis-0"> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:height'} /> <Trans i18nKey={'common:formField:height'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="cm" placeholder="cm"
type="number" type="number"
{...field} {...field}
value={field.value ?? ''} value={field.value ?? ''}
onChange={(e) => onChange={(e) =>
field.onChange( field.onChange(
e.target.value === '' e.target.value === '' ? null : Number(e.target.value),
? null )
: Number(e.target.value), }
) />
} </FormControl>
/> <FormMessage />
</FormControl> </FormItem>
<FormMessage /> )}
</FormItem> />
)} </div>
/>
</div>
</>
)}
<FormField <FormField
name="userConsent" name="userConsent"
@@ -273,7 +227,7 @@ export function UpdateAccountForm({
</div> </div>
<Link <Link
href={t('account:updateAccount:userConsentUrlPath')} href={''}
className="flex flex-row items-center gap-2 text-sm hover:underline" className="flex flex-row items-center gap-2 text-sm hover:underline"
target="_blank" target="_blank"
> >

View File

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

View File

@@ -1,13 +1,17 @@
'use server'; 'use server';
import { redirect } from 'next/navigation';
import { updateCustomer } from '@lib/data/customer'; import { updateCustomer } from '@lib/data/customer';
import { AccountSubmitData, createAuthApi } from '@kit/auth/api'; import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { pathsConfig } from '@kit/shared/config';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { UpdateAccountSchemaServer } from '../schemas/update-account.schema'; import { pathsConfig } from '@kit/shared/config';
import { UpdateAccountSchema } from '../schemas/update-account.schema';
export const onUpdateAccount = enhanceAction( export const onUpdateAccount = enhanceAction(
async (params: AccountSubmitData) => { async (params: AccountSubmitData) => {
@@ -31,16 +35,19 @@ export const onUpdateAccount = enhanceAction(
phone: params.phone, phone: params.phone,
}); });
} catch (e) { } catch (e) {
console.error('Failed to update Medusa customer', e); console.error("Failed to update Medusa customer", e);
} }
const hasUnseenMembershipConfirmation = const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation(); await api.hasUnseenMembershipConfirmation();
return {
hasUnseenMembershipConfirmation, if (hasUnseenMembershipConfirmation) {
}; redirect(pathsConfig.auth.membershipConfirmation);
} else {
redirect(pathsConfig.app.selectPackage);
}
}, },
{ {
schema: UpdateAccountSchemaServer, schema: UpdateAccountSchema,
}, },
); );

View File

@@ -1,5 +1,6 @@
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) { async function SiteLayout(props: React.PropsWithChildren) {
return ( return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}> <div className={'flex min-h-[100vh] flex-col items-center justify-center'}>

View File

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

View File

@@ -1,4 +1,5 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,10 +1,11 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { MultiFactorChallengeContainer } from '@kit/auth/mfa'; import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
import { pathsConfig } from '@kit/shared/config';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
@@ -40,8 +41,7 @@ async function VerifyPage(props: Props) {
} }
const nextPath = (await props.searchParams).next; const nextPath = (await props.searchParams).next;
const redirectPath = const redirectPath = !!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
!!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
return ( return (
<MultiFactorChallengeContainer <MultiFactorChallengeContainer

View File

@@ -1,149 +0,0 @@
'use client';
import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
import { format } from 'date-fns';
import { Info } from 'lucide-react';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { AnalysisElement } from '~/lib/services/analysis-element.service';
import AnalysisLevelBar, {
AnalysisLevelBarSkeleton,
AnalysisResultLevel,
} from './analysis-level-bar';
export type AnalysisResultForDisplay = Pick<
UserAnalysisElement,
| 'norm_status'
| 'response_value'
| 'unit'
| 'norm_lower_included'
| 'norm_upper_included'
| 'norm_lower'
| 'norm_upper'
| 'response_time'
>;
export enum AnalysisStatus {
NORMAL = 0,
MEDIUM = 1,
HIGH = 2,
}
const AnalysisDoctor = ({
analysisElement,
results,
startIcon,
endIcon,
isCancelled,
}: {
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
results?: AnalysisResultForDisplay;
isCancelled?: boolean;
startIcon?: ReactElement | null;
endIcon?: ReactNode | null;
}) => {
const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status || AnalysisStatus.NORMAL;
const value = results?.response_value || 0;
const unit = results?.unit || '';
const normLowerIncluded = results?.norm_lower_included || false;
const normUpperIncluded = results?.norm_upper_included || false;
const normLower = results?.norm_lower || 0;
const normUpper = results?.norm_upper || 0;
const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => {
if (!results) {
return null;
}
const isUnderNorm = value < normLower;
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;
}
}, [results, value, normLower]);
return (
<div className="border-border rounded-lg border px-5">
<div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
<div className="flex items-center gap-2 font-semibold">
{startIcon || <div className="w-4" />}
{name}
{results?.response_time && (
<div
className="group/tooltip relative"
onClick={(e) => {
e.stopPropagation();
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 },
)}
>
<Trans i18nKey="analysis-results:analysisDate" />
{': '}
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')}
</div>
</div>
)}
</div>
{results ? (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div>
<div className="text-muted-foreground text-sm">{unit}</div>
</div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
{normLower} - {normUpper}
<div>
<Trans i18nKey="analysis-results:results.range.normal" />
</div>
</div>
<AnalysisLevelBar
results={results}
normLowerIncluded={normLowerIncluded}
normUpperIncluded={normUpperIncluded}
level={analysisResultLevel!}
/>
{endIcon || <div className="mx-2 w-4" />}
</>
) : isCancelled ? null : (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
)}
</div>
</div>
);
};
export default AnalysisDoctor;

View File

@@ -1,132 +0,0 @@
import { useMemo } from 'react';
import { ArrowDown } from 'lucide-react';
import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis-doctor';
export enum AnalysisResultLevel {
VERY_LOW = 0,
LOW = 1,
NORMAL = 2,
HIGH = 3,
VERY_HIGH = 4,
}
const Level = ({
isActive = false,
color,
isFirst = false,
isLast = false,
arrowLocation,
}: {
isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean;
isLast?: boolean;
arrowLocation?: number;
}) => {
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]"
style={{ left: `${arrowLocation}%` }}
>
<ArrowDown strokeWidth={2} />
</div>
)}
</div>
);
};
export const AnalysisLevelBarSkeleton = () => {
return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" />
</div>
);
};
const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level,
results,
}: {
normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel;
results: AnalysisResultForDisplay;
}) => {
const {
norm_lower: lower,
norm_upper: upper,
response_value: value,
} = results;
const arrowLocation = useMemo(() => {
if (value < lower!) {
return 0;
}
if (normLowerIncluded || normUpperIncluded) {
return 50;
}
const calculated = ((value - lower!) / (upper! - lower!)) * 100;
if (calculated > 100) {
return 100;
}
return calculated;
}, [value, upper, lower]);
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(
() => [
level === AnalysisResultLevel.VERY_LOW,
level === AnalysisResultLevel.LOW,
level === AnalysisResultLevel.HIGH,
level === AnalysisResultLevel.VERY_HIGH,
],
[level, value, upper, lower],
);
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
{normLowerIncluded && (
<>
<Level isActive={isVeryLow} color="destructive" isFirst />
<Level isActive={isLow} color="warning" />
</>
)}
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
{...(hasAbnormalLevel
? { color: 'warning', isActive: false }
: { color: 'success', isActive: true })}
arrowLocation={arrowLocation}
/>
{normUpperIncluded && (
<>
<Level isActive={isHigh} color="warning" />
<Level isActive={isVeryHigh} color="destructive" isLast />
</>
)}
</div>
);
};
export default AnalysisLevelBar;

View File

@@ -23,7 +23,9 @@ import {
doctorAnalysisFeedbackFormSchema, doctorAnalysisFeedbackFormSchema,
} from '@kit/doctor/schema/doctor-analysis.schema'; } from '@kit/doctor/schema/doctor-analysis.schema';
import ConfirmationModal from '@kit/shared/components/confirmation-modal'; import ConfirmationModal from '@kit/shared/components/confirmation-modal';
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks'; import {
useCurrentLocaleLanguageNames
} from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';

View File

@@ -13,7 +13,7 @@ import {
} from '@kit/ui/collapsible'; } from '@kit/ui/collapsible';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import AnalysisDoctor from './analysis-doctor'; import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis';
export default function DoctorAnalysisWrapper({ export default function DoctorAnalysisWrapper({
analysisData, analysisData,
@@ -29,7 +29,7 @@ export default function DoctorAnalysisWrapper({
asChild asChild
> >
<div className="[&[data-state=open]_.caret-icon]:rotate-180"> <div className="[&[data-state=open]_.caret-icon]:rotate-180">
<AnalysisDoctor <Analysis
startIcon={ startIcon={
analysisData.latestPreviousAnalysis && ( analysisData.latestPreviousAnalysis && (
<CaretDownIcon className="caret-icon transition-transform duration-200" /> <CaretDownIcon className="caret-icon transition-transform duration-200" />
@@ -65,7 +65,7 @@ export default function DoctorAnalysisWrapper({
{analysisData.latestPreviousAnalysis && ( {analysisData.latestPreviousAnalysis && (
<CollapsibleContent> <CollapsibleContent>
<div className="my-1 flex flex-col"> <div className="my-1 flex flex-col">
<AnalysisDoctor <Analysis
endIcon={ endIcon={
analysisData.latestPreviousAnalysis.comment && ( analysisData.latestPreviousAnalysis.comment && (
<> <>

View File

@@ -6,7 +6,6 @@ import {
getUserDoneResponsesAction, getUserDoneResponsesAction,
getUserInProgressResponsesAction, getUserInProgressResponsesAction,
} from '@kit/doctor/actions/table-data-fetching-actions'; } from '@kit/doctor/actions/table-data-fetching-actions';
import ResultsTableWrapper from './results-table-wrapper'; import ResultsTableWrapper from './results-table-wrapper';
export default function Dashboard() { export default function Dashboard() {

View File

@@ -1,7 +1,8 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { isDoctor } from '@kit/doctor/lib/server/utils/is-doctor';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isDoctor } from '@kit/doctor/lib/server/utils/is-doctor';
type LayoutOrPageComponent<Params> = React.ComponentType<Params>; type LayoutOrPageComponent<Params> = React.ComponentType<Params>;

View File

@@ -5,7 +5,6 @@ import { useTransition } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { capitalize } from 'lodash';
import { Eye } from 'lucide-react'; import { Eye } from 'lucide-react';
import { getResultSetName } from '@kit/doctor/lib/helpers'; import { getResultSetName } from '@kit/doctor/lib/helpers';
@@ -27,6 +26,7 @@ import {
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import DoctorJobSelect from './doctor-job-select'; import DoctorJobSelect from './doctor-job-select';
import { capitalize } from 'lodash';
export default function ResultsTable({ export default function ResultsTable({
results = [], results = [],
@@ -189,9 +189,7 @@ export default function ResultsTable({
</TableCell> </TableCell>
<TableCell> <TableCell>
{capitalize( {capitalize(
languageNames.of( languageNames.of(result?.patient?.preferred_locale ?? 'et'),
result?.patient?.preferred_locale ?? 'et',
),
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -50,3 +50,5 @@ async function AnalysisPage({
export default DoctorGuard(AnalysisPage); export default DoctorGuard(AnalysisPage);
const loadResult = cache(getAnalysisResultsForDoctor); const loadResult = cache(getAnalysisResultsForDoctor);

View File

@@ -5,6 +5,7 @@ import { cookies } from 'next/headers';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace'; import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
import { DoctorSidebar } from './_components/doctor-sidebar'; import { DoctorSidebar } from './_components/doctor-sidebar';
import { DoctorMobileNavigation } from './_components/mobile-navigation'; import { DoctorMobileNavigation } from './_components/mobile-navigation';

View File

@@ -5,12 +5,12 @@ import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react'; import { ArrowLeft, MessageCircle } from 'lucide-react';
import { useCaptureException } from '@kit/monitoring/hooks'; import { useCaptureException } from '@kit/monitoring/hooks';
import { RootProviders } from '@kit/shared/components/root-providers';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { SiteHeader } from '~/(marketing)/_components/site-header'; import { SiteHeader } from '~/(marketing)/_components/site-header';
import { RootProviders } from '@kit/shared/components/root-providers';
const GlobalErrorPage = ({ const GlobalErrorPage = ({
error, error,

View File

@@ -1,7 +1,4 @@
import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip'; import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
@@ -25,15 +22,14 @@ export default async function AnalysisResultsPage({
id: string; id: string;
}>; }>;
}) { }) {
const { id: analysisOrderId } = await params; const account = await loadCurrentUserAccount();
const [{ account }, analysisResponse] = await Promise.all([ const { id: analysisResponseId } = await params;
loadCurrentUserAccount(),
loadUserAnalysis(Number(analysisOrderId)),
]);
if (!account?.id) { const analysisResponse = await loadUserAnalysis(Number(analysisResponseId));
return redirect('/');
if (!account?.id || !analysisResponse) {
return null;
} }
await createPageViewLog({ await createPageViewLog({
@@ -41,62 +37,46 @@ export default async function AnalysisResultsPage({
action: PageViewAction.VIEW_ANALYSIS_RESULTS, action: PageViewAction.VIEW_ANALYSIS_RESULTS,
}); });
if (!analysisResponse) {
return (
<>
<PageHeader
title={<Trans i18nKey="analysis-results:pageTitle" />}
description={<Trans i18nKey="analysis-results:descriptionEmpty" />}
/>
<PageBody className="gap-4"></PageBody>
</>
);
}
const orderedAnalysisElements = analysisResponse.orderedAnalysisElements;
const hasOrderedAnalysisElements = orderedAnalysisElements.length > 0;
const isPartialStatus =
analysisResponse.order.status === 'PARTIAL_ANALYSIS_RESPONSE';
return ( return (
<> <>
<PageHeader <PageHeader />
title={<Trans i18nKey="analysis-results:pageTitle" />} <PageBody className="gap-4">
description={ <div className="mt-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
hasOrderedAnalysisElements ? ( <div>
isPartialStatus ? ( <h4>
<Trans i18nKey="analysis-results:descriptionPartial" /> <Trans i18nKey="analysis-results:pageTitle" />
) : ( </h4>
<Trans i18nKey="analysis-results:description" /> <p className="text-muted-foreground text-sm">
) {analysisResponse?.elements &&
) : ( analysisResponse.elements?.length > 0 ? (
<Trans i18nKey="analysis-results:descriptionEmpty" /> <Trans i18nKey="analysis-results:description" />
) ) : (
} <Trans i18nKey="analysis-results:descriptionEmpty" />
> )}
<div> </p>
</div>
<Button asChild> <Button asChild>
<Link href={pathsConfig.app.orderAnalysisPackage}> <Link href={pathsConfig.app.orderAnalysisPackage}>
<Trans i18nKey="analysis-results:orderNewAnalysis" /> <Trans i18nKey="analysis-results:orderNewAnalysis" />
</Link> </Link>
</Button> </Button>
</div> </div>
</PageHeader>
<PageBody className="gap-4 pt-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<h5 className="break-all"> <h4>
<Trans <Trans
i18nKey="analysis-results:orderTitle" i18nKey="analysis-results:orderTitle"
values={{ orderNumber: analysisResponse.order.medusaOrderId }} values={{ orderNumber: analysisResponse.order.medusa_order_id }}
/>
</h4>
<h5>
<Trans
i18nKey={`orders:status.${analysisResponse.order.status}`}
/> />
</h5>
<h6>
<Trans i18nKey={`orders:status.${analysisResponse.order.status}`} />
<ButtonTooltip <ButtonTooltip
content={`${analysisResponse.order.createdAt ? new Date(analysisResponse?.order?.createdAt).toLocaleString() : ''}`} content={`${analysisResponse.order.created_at ? new Date(analysisResponse?.order?.created_at).toLocaleString() : ''}`}
className="ml-6" className="ml-6"
/> />
</h6> </h5>
</div> </div>
{analysisResponse?.summary?.value && ( {analysisResponse?.summary?.value && (
<div> <div>
@@ -107,20 +87,13 @@ export default async function AnalysisResultsPage({
</div> </div>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{orderedAnalysisElements ? ( {analysisResponse.elements ? (
orderedAnalysisElements.map((element, index) => ( analysisResponse.elements.map((element, index) => (
<React.Fragment key={element.analysisIdOriginal}> <Analysis
<Analysis element={element} /> key={index}
{element.results?.nestedElements?.map( analysisElement={{ analysis_name_lab: element.analysis_name }}
(nestedElement, nestedIndex) => ( results={element}
<Analysis />
key={`nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
nestedElement={nestedElement}
isNestedElement
/>
),
)}
</React.Fragment>
)) ))
) : ( ) : (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">

View File

@@ -1,15 +1,17 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { AnalysisResultDetailsElementResults } from '@/packages/features/user-analyses/src/types/analysis-results';
import { AnalysisResultLevel } from '@/packages/features/user-analyses/src/types/analysis-results';
import { ArrowDown } from 'lucide-react'; import { ArrowDown } from 'lucide-react';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis';
type AnalysisResultLevelBarResults = Pick< export enum AnalysisResultLevel {
AnalysisResultDetailsElementResults, VERY_LOW = 0,
'normLower' | 'normUpper' | 'responseValue' LOW = 1,
>; NORMAL = 2,
HIGH = 3,
VERY_HIGH = 4,
}
const Level = ({ const Level = ({
isActive = false, isActive = false,
@@ -17,19 +19,17 @@ const Level = ({
isFirst = false, isFirst = false,
isLast = false, isLast = false,
arrowLocation, arrowLocation,
normRangeText,
}: { }: {
isActive?: boolean; isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200'; color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean; isFirst?: boolean;
isLast?: boolean; isLast?: boolean;
arrowLocation?: number; arrowLocation?: number;
normRangeText?: string | null;
}) => { }) => {
return ( return (
<div <div
className={cn(`bg-${color} relative h-3 flex-1`, { className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-60': !isActive, 'opacity-20': !isActive,
'rounded-l-lg': isFirst, 'rounded-l-lg': isFirst,
'rounded-r-lg': isLast, 'rounded-r-lg': isLast,
})} })}
@@ -37,171 +37,96 @@ const Level = ({
{isActive && ( {isActive && (
<div <div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]" className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
{...(arrowLocation style={{ left: `${arrowLocation}%` }}
? {
style: {
left: `${arrowLocation}%`,
...(arrowLocation > 92.5 && { left: '92.5%' }),
...(arrowLocation < 7.5 && { left: '7.5%' }),
},
}
: {})}
> >
<ArrowDown strokeWidth={2} /> <ArrowDown strokeWidth={2} />
</div> </div>
)} )}
</div>
);
};
{color === 'success' && typeof normRangeText === 'string' && ( export const AnalysisLevelBarSkeleton = () => {
<p return (
className={cn( <div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0">
'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap', <Level color="gray-200" />
{
'opacity-60': isActive,
},
)}
>
{normRangeText}
</p>
)}
</div> </div>
); );
}; };
const AnalysisLevelBar = ({ const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level, level,
results: { normLower: lower, normUpper: upper, responseValue: value }, results,
normRangeText,
}: { }: {
normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel; level: AnalysisResultLevel;
results: AnalysisResultLevelBarResults; results: AnalysisResultForDisplay;
normRangeText: string | null;
}) => { }) => {
// Calculate arrow position based on value within normal range
const { norm_lower: lower, norm_upper: upper, response_value: value } = results;
const arrowLocation = useMemo(() => { const arrowLocation = useMemo(() => {
// If no response value, center the arrow if (value < lower!) {
if (value === null || value === undefined) { return 0;
}
if (normLowerIncluded || normUpperIncluded) {
return 50; return 50;
} }
// If no normal ranges defined, center the arrow const calculated = ((value - lower!) / (upper! - lower!)) * 100;
if (lower === null && upper === null) {
return 50; if (calculated > 100) {
return 100;
} }
// If only upper bound exists return calculated;
if (lower === null && upper !== null) {
if (value <= upper) {
return Math.min(75, (value / upper) * 75); // Show in left 75% of normal range
}
return 100; // Beyond upper bound
}
// If only lower bound exists
if (upper === null && lower !== null) {
if (value >= lower) {
// Value is in normal range (above lower bound)
// Position proportionally in the normal range section
const normalizedPosition = Math.min((value - lower) / (lower * 0.5), 1); // Use 50% of lower as scale
return normalizedPosition * 100;
}
// Value is below lower bound - position in the "below normal" section
const belowPosition = Math.max(0, Math.min(1, value / lower));
return belowPosition * 100;
}
// Both bounds exist
if (lower !== null && upper !== null) {
if (value < lower) {
return 0; // Below normal range
}
if (value > upper) {
return 100; // Above normal range
}
// Within normal range
return ((value - lower) / (upper - lower)) * 100;
}
return 50; // Fallback
}, [value, upper, lower]); }, [value, upper, lower]);
// Determine level states based on normStatus const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [
const isNormal = level === AnalysisResultLevel.NORMAL; level === AnalysisResultLevel.VERY_LOW,
const isWarning = level === AnalysisResultLevel.WARNING; level === AnalysisResultLevel.LOW,
const isCritical = level === AnalysisResultLevel.CRITICAL; level === AnalysisResultLevel.HIGH,
const isPending = level === null; level === AnalysisResultLevel.VERY_HIGH,
], [level, value, upper, lower]);
// If pending results, show gray bar const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
if (isPending) {
return (
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
);
}
// Show appropriate levels based on available norm bounds
const hasLowerBound = lower !== null;
const hasUpperBound = upper !== null;
// Determine which section the value falls into
const isValueBelowLower = hasLowerBound && value !== null && value < lower!;
const isValueAboveUpper = hasUpperBound && value !== null && value > upper!;
const isValueInNormalRange = !isValueBelowLower && !isValueAboveUpper;
const [first, second, third] = useMemo(() => {
const [warning, normal, critical] = [
{
isActive: isWarning,
color: 'warning',
...(isWarning ? { arrowLocation } : {}),
},
{
isActive: isNormal,
color: 'success',
normRangeText,
...(isNormal ? { arrowLocation } : {}),
},
{
isActive: isCritical,
color: 'destructive',
isLast: true,
...(isCritical ? { arrowLocation } : {}),
},
] as const;
if (!hasLowerBound) {
return [{ ...normal, isFirst: true }, warning, critical] as const;
}
return [
{ ...warning, isFirst: true },
normal,
{ ...critical, isLast: true },
] as const;
}, [
isValueBelowLower,
isValueAboveUpper,
isValueInNormalRange,
arrowLocation,
normRangeText,
isNormal,
isWarning,
isCritical,
]);
return ( return (
<div <div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0">
className={cn( {normLowerIncluded && (
'flex h-3 gap-1', <>
'mt-4 sm:mt-0', <Level
'w-[60%] sm:w-[35%]', isActive={isVeryLow}
'min-w-[50vw] sm:min-w-auto', color="destructive"
'max-w-[360px]', isFirst
/>
<Level isActive={isLow} color="warning" />
</>
)}
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
{...(hasAbnormalLevel ? { color: "warning", isActive: false } : { color: "success", isActive: true })}
arrowLocation={arrowLocation}
/>
{normUpperIncluded && (
<>
<Level
isActive={isHigh}
color="warning"
/>
<Level
isActive={isVeryHigh}
color="destructive"
isLast
/>
</>
)} )}
>
<Level {...first} />
<Level {...second} />
<Level {...third} />
</div> </div>
); );
}; };

View File

@@ -1,142 +1,93 @@
'use client'; 'use client';
import React, { useMemo, useState } from 'react'; import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import type { import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
AnalysisResultDetailsElement,
AnalysisResultsDetailsElementNested,
} from '@/packages/features/user-analyses/src/types/analysis-results';
import { AnalysisResultLevel } from '@/packages/features/user-analyses/src/types/analysis-results';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import AnalysisLevelBar from './analysis-level-bar'; import { AnalysisElement } from '~/lib/services/analysis-element.service';
import AnalysisLevelBar, {
AnalysisLevelBarSkeleton,
AnalysisResultLevel,
} from './analysis-level-bar';
export type AnalysisResultForDisplay = Pick<
UserAnalysisElement,
| 'norm_status'
| 'response_value'
| 'unit'
| 'norm_lower_included'
| 'norm_upper_included'
| 'norm_lower'
| 'norm_upper'
| 'response_time'
>;
export enum AnalysisStatus {
NORMAL = 0,
MEDIUM = 1,
HIGH = 2,
}
const Analysis = ({ const Analysis = ({
element: elementOriginal, analysisElement,
nestedElement, results,
isNestedElement = false, startIcon,
endIcon,
isCancelled,
}: { }: {
element?: AnalysisResultDetailsElement; analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
nestedElement?: AnalysisResultsDetailsElementNested; results?: AnalysisResultForDisplay;
isNestedElement?: boolean; isCancelled?: boolean;
startIcon?: ReactElement | null;
endIcon?: ReactNode | null;
}) => { }) => {
const { t } = useTranslation(); const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status || AnalysisStatus.NORMAL;
const value = results?.response_value || 0;
const unit = results?.unit || '';
const normLowerIncluded = results?.norm_lower_included || false;
const normUpperIncluded = results?.norm_upper_included || false;
const normLower = results?.norm_lower || 0;
const normUpper = results?.norm_upper || 0;
const element = (() => { const [showTooltip, setShowTooltip] = useState(false);
if (isNestedElement) { const analysisResultLevel = useMemo(() => {
return nestedElement!;
}
return elementOriginal!;
})();
const results: AnalysisResultDetailsElement['results'] = useMemo(() => {
if (isNestedElement) {
const nestedElement = element as AnalysisResultsDetailsElementNested;
return {
analysisElementOriginalId: nestedElement.analysisElementOriginalId,
normLower: nestedElement.normLower,
normUpper: nestedElement.normUpper,
normStatus: nestedElement.normStatus,
responseTime: nestedElement.responseTime,
responseValue: nestedElement.responseValue,
responseValueIsNegative: nestedElement.responseValueIsNegative,
responseValueIsWithinNorm: nestedElement.responseValueIsWithinNorm,
normLowerIncluded: nestedElement.normLowerIncluded,
normUpperIncluded: nestedElement.normUpperIncluded,
unit: nestedElement.unit,
status: nestedElement.status,
nestedElements: [],
};
}
return (element as AnalysisResultDetailsElement).results;
}, [element, isNestedElement]);
const name = element.analysisName || '';
const hasIsWithinNorm = results?.responseValueIsWithinNorm !== null;
const hasIsNegative = results?.responseValueIsNegative !== null;
const value = (() => {
if (!results) { if (!results) {
return null; return null;
} }
const { const isUnderNorm = value < normLower;
responseValue, if (isUnderNorm) {
responseValueIsNegative, switch (status) {
responseValueIsWithinNorm, case AnalysisStatus.MEDIUM:
} = results; return AnalysisResultLevel.LOW;
if (responseValue === null || responseValue === undefined) { default:
if (hasIsNegative) { return AnalysisResultLevel.VERY_LOW;
if (responseValueIsNegative) {
return t('analysis-results:results.value.negative');
}
return t('analysis-results:results.value.positive');
} }
if (hasIsWithinNorm) {
if (responseValueIsWithinNorm) {
return t('analysis-results:results.value.isWithinNorm');
}
return t('analysis-results:results.value.isNotWithinNorm');
}
return null;
} }
switch (status) {
return responseValue; case AnalysisStatus.MEDIUM:
})(); return AnalysisResultLevel.HIGH;
const unit = results?.unit || ''; case AnalysisStatus.HIGH:
const normLower = results?.normLower ?? null; return AnalysisResultLevel.VERY_HIGH;
const normUpper = results?.normUpper ?? null;
const normStatus = results?.normStatus ?? null;
const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => {
if (normStatus === null) {
return null;
}
switch (normStatus) {
case 1:
return AnalysisResultLevel.WARNING;
case 2:
return AnalysisResultLevel.CRITICAL;
case 0:
default: default:
return AnalysisResultLevel.NORMAL; return AnalysisResultLevel.NORMAL;
} }
}, [normStatus]); }, [results, value, normLower]);
const isCancelled = Number(results?.status) === 5;
const nestedElements = results?.nestedElements ?? null;
const hasNestedElements =
Array.isArray(nestedElements) && nestedElements.length > 0;
const normRangeText = (() => {
if (normLower === null && normUpper === null) {
return null;
}
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
})();
const hasTextualResponse = hasIsNegative || hasIsWithinNorm;
return ( return (
<div <div className="border-border rounded-lg border px-5">
className={cn('border-border rounded-lg border px-5', { <div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
'ml-8': isNestedElement, <div className="flex items-center gap-2 font-semibold">
})} {startIcon || <div className="w-4" />}
>
<div className="flex flex-col items-center justify-between gap-2 pt-3 pb-6 sm:h-[65px] sm:flex-row sm:gap-0 sm:py-3">
<div
className={cn('flex items-center gap-2 font-semibold', {
'font-bold': isNestedElement,
})}
>
{name} {name}
{results?.responseTime && ( {results?.response_time && (
<div <div
className="group/tooltip relative" className="group/tooltip relative"
onClick={(e) => { onClick={(e) => {
@@ -154,55 +105,42 @@ const Analysis = ({
> >
<Trans i18nKey="analysis-results:analysisDate" /> <Trans i18nKey="analysis-results:analysisDate" />
{': '} {': '}
{format(new Date(results.responseTime), 'dd.MM.yyyy HH:mm')} {format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')}
</div> </div>
</div> </div>
)} )}
</div> </div>
{results ? (
{isCancelled && (
<div className="text-sm font-semibold text-red-600">
<Trans i18nKey="analysis-results:cancelled" />
</div>
)}
{isCancelled || !results || hasNestedElements ? null : (
<> <>
<div className="flex items-center gap-3 sm:ml-auto"> <div className="flex items-center gap-3 sm:ml-auto">
<div <div className="font-semibold">{value}</div>
className={cn('font-semibold', {
'text-yellow-600':
hasTextualResponse &&
analysisResultLevel === AnalysisResultLevel.WARNING,
'text-red-600':
hasTextualResponse &&
analysisResultLevel === AnalysisResultLevel.CRITICAL,
'text-green-600':
hasTextualResponse &&
analysisResultLevel === AnalysisResultLevel.NORMAL,
})}
>
{value}
</div>
<div className="text-muted-foreground text-sm">{unit}</div> <div className="text-muted-foreground text-sm">{unit}</div>
</div> </div>
{!hasTextualResponse && ( <div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
<> {normLower} - {normUpper}
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0"> <div>
{normRangeText} <Trans i18nKey="analysis-results:results.range.normal" />
<div> </div>
<Trans i18nKey="analysis-results:results.range.normal" /> </div>
</div> <AnalysisLevelBar
</div> results={results}
<AnalysisLevelBar normLowerIncluded={normLowerIncluded}
results={results} normUpperIncluded={normUpperIncluded}
level={analysisResultLevel!} level={analysisResultLevel!}
normRangeText={normRangeText} />
/> {endIcon || <div className="mx-2 w-4" />}
</>
)}
</> </>
)} ) : (isCancelled ? null : (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
))}
</div> </div>
</div> </div>
); );

View File

@@ -1,108 +0,0 @@
'use client';
import React, { useState } from 'react';
import Modal from '@modules/common/components/modal';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Button } from '@kit/ui/shadcn/button';
import { Trans } from '@kit/ui/trans';
import Analysis from '../_components/analysis';
import { analysisResponses } from './test-responses';
export default function AnalysisResultsPage() {
const [openBlocks, setOpenBlocks] = useState<number[]>([]);
return (
<>
<PageHeader />
<PageBody className="gap-4">
<div className="mt-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
<div>
<h2>Analüüsi tulemused demo</h2>
</div>
</div>
<div className="flex flex-col gap-2">
{analysisResponses.map(({ id, orderedAnalysisElements }, index) => {
const isOpen = openBlocks.includes(id);
const closeModal = () =>
setOpenBlocks(openBlocks.filter((block) => block !== id));
return (
<div key={index} className="flex flex-col gap-2 py-4">
<div className="flex flex-col gap-2 pb-4">
<h3>AnalysisOrderId: {id}</h3>
<div className="flex flex-col gap-2">
<h5>OrderedAnalysisElements</h5>
<Button
onClick={() => {
if (isOpen) {
closeModal();
} else {
setOpenBlocks([...openBlocks, id]);
}
}}
className="w-fit"
color={isOpen ? 'orange' : 'grey'}
>
{isOpen ? 'Close' : 'Open'}
</Button>
{isOpen && (
<Modal isOpen={isOpen} close={closeModal} size="large">
<div className="overflow-y-auto">
<p>NormiStaatus</p>
<ul>
<li>
0 - testi väärtus jääb normaalväärtuste piirkonda
või on määramata,
</li>
<li>
1 - testi väärtus jääb hoiatava (tähelepanu
suunava) märkega piirkonda,
</li>
<li>
2 - testi väärtus on normaalväärtuste piirkonnast
väljas või kõrgendatud tähelepanu nõudvas
piirkonnas.
</li>
</ul>
<p>UuringOlek</p>
<ul>
<li>1 - Järjekorras,</li>
<li>2 - Ootel,</li>
<li>3 - Töös,</li>
<li>4 - Lõpetatud,</li>
<li>5 - Tagasi lükatud,</li>
<li>6 - Tühistatud,</li>
</ul>
<pre className="bg-muted rounded-md p-4 text-sm">
{JSON.stringify(orderedAnalysisElements, null, 2)}
</pre>
</div>
</Modal>
)}
</div>
{orderedAnalysisElements ? (
orderedAnalysisElements.map((element, index) => (
<Analysis key={index} element={element} />
))
) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisElements" />
</div>
)}
</div>
<hr />
</div>
);
})}
</div>
</PageBody>
</>
);
}

View File

@@ -1,906 +0,0 @@
import type { AnalysisResultDetailsMapped } from '@/packages/features/user-analyses/src/types/analysis-results';
type AnalysisTestResponse = Omit<
AnalysisResultDetailsMapped,
'order' | 'orderedAnalysisElementIds' | 'summary' | 'elements'
>;
const empty1: AnalysisTestResponse = {
id: 1,
orderedAnalysisElements: [],
};
const big1: AnalysisTestResponse = {
id: 2,
orderedAnalysisElements: [
{
analysisIdOriginal: '1744-2',
isWaitingForResults: false,
analysisName: 'ALAT',
results: {
nestedElements: [],
unit: 'U/l',
normLower: null,
normUpper: 45,
normStatus: 2,
responseTime: '2024-02-29T10:42:25+00:00',
responseValue: 84,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '1744-2',
},
},
{
analysisIdOriginal: '1920-8',
isWaitingForResults: false,
analysisName: 'ASAT',
results: {
nestedElements: [],
unit: 'U/l',
normLower: 15,
normUpper: 45,
normStatus: 0,
responseTime: '2024-02-29T10:20:55+00:00',
responseValue: 45,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '1920-8',
},
},
{
analysisIdOriginal: '1988-5',
isWaitingForResults: false,
analysisName: 'CRP',
results: {
nestedElements: [],
unit: 'mg/l',
normLower: null,
normUpper: 5,
normStatus: 0,
responseTime: '2024-02-29T10:18:49+00:00',
responseValue: 0.79,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '1988-5',
},
},
{
analysisIdOriginal: '57747-8',
isWaitingForResults: false,
analysisName: 'Erütrotsüüdid',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 5,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '57747-8',
},
},
{
analysisIdOriginal: '2276-4',
isWaitingForResults: false,
analysisName: 'Ferritiin',
results: {
nestedElements: [],
unit: 'µg/l',
normLower: 28,
normUpper: 370,
normStatus: 0,
responseTime: '2024-02-29T10:46:54+00:00',
responseValue: 204.1,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '2276-4',
},
},
{
analysisIdOriginal: '14771-0',
isWaitingForResults: false,
analysisName: 'Glükoos',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 4.1,
normUpper: 6,
normStatus: 0,
responseTime: '2024-02-29T10:06:24+00:00',
responseValue: 5.4,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14771-0',
},
},
{
analysisIdOriginal: '59156-0',
isWaitingForResults: false,
analysisName: 'Glükoos',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 2,
normStatus: 2,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: null,
responseValueIsWithinNorm: false,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '59156-0',
},
},
{
analysisIdOriginal: '59156-0',
isWaitingForResults: false,
analysisName: 'Glükoos',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 2,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: null,
responseValueIsWithinNorm: true,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '59156-0',
},
},
{
analysisIdOriginal: '13955-0',
isWaitingForResults: false,
analysisName: 'HCV Ab',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: null,
normStatus: 0,
responseTime: '2024-02-29T13:44:48+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '13955-0',
},
},
{
analysisIdOriginal: '14646-4',
isWaitingForResults: false,
analysisName: 'HDL kolesterool',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 1,
normUpper: null,
normStatus: 1,
responseTime: '2024-02-29T10:20:55+00:00',
responseValue: 0.8,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14646-4',
},
},
{
analysisIdOriginal: '2000-8',
isWaitingForResults: false,
analysisName: 'Kaltsium',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 2.1,
normUpper: 2.55,
normStatus: 0,
responseTime: '2024-02-29T10:12:10+00:00',
responseValue: 2.49,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '2000-8',
},
},
{
analysisIdOriginal: '59158-6',
isWaitingForResults: false,
analysisName: 'Ketokehad',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 0.5,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '59158-6',
},
},
{
analysisIdOriginal: '14647-2',
isWaitingForResults: false,
analysisName: 'Kolesterool',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: null,
normUpper: 5,
normStatus: 1,
responseTime: '2024-02-29T10:20:34+00:00',
responseValue: 5.7,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14647-2',
},
},
{
analysisIdOriginal: '14682-9',
isWaitingForResults: false,
analysisName: 'Kreatiniin',
results: {
nestedElements: [],
unit: 'µmol/l',
normLower: 64,
normUpper: 111,
normStatus: 0,
responseTime: '2024-02-29T10:19:00+00:00',
responseValue: 89,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14682-9',
},
},
{
analysisIdOriginal: '22748-8',
isWaitingForResults: false,
analysisName: 'LDL kolesterool',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: null,
normUpper: 3,
normStatus: 1,
responseTime: '2024-02-29T10:21:15+00:00',
responseValue: 4.3,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '22748-8',
},
},
{
analysisIdOriginal: '58805-3',
isWaitingForResults: false,
analysisName: 'Leukotsüüdid',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 10,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '58805-3',
},
},
{
analysisIdOriginal: '2601-3',
isWaitingForResults: false,
analysisName: 'Magneesium',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 0.66,
normUpper: 1.07,
normStatus: 0,
responseTime: '2024-02-29T10:17:26+00:00',
responseValue: 0.82,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '2601-3',
},
},
{
analysisIdOriginal: '70204-3',
isWaitingForResults: false,
analysisName: 'Mitte-HDL kolesterool',
results: {
nestedElements: [],
labComment: 'Mitte-paastu veri <3,9 mmol/L',
unit: 'mmol/l',
normLower: null,
normUpper: 3.8,
normStatus: 1,
responseTime: '2024-02-29T10:20:55+00:00',
responseValue: 4.9,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '70204-3',
},
},
{
analysisIdOriginal: '14798-3',
isWaitingForResults: false,
analysisName: 'Raud',
results: {
nestedElements: [],
unit: 'µmol/l',
normLower: 11.6,
normUpper: 31.3,
normStatus: 0,
responseTime: '2024-02-29T10:21:16+00:00',
responseValue: 16.5,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14798-3',
},
},
{
analysisIdOriginal: '14927-8',
isWaitingForResults: false,
analysisName: 'Triglütseriidid',
results: {
nestedElements: [],
labComment: 'Mitte-paastu veri <2,0 mmol/L',
unit: 'mmol/l',
normLower: null,
normUpper: 1.7,
normStatus: 1,
responseTime: '2024-02-29T10:21:16+00:00',
responseValue: 1.89,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14927-8',
},
},
{
analysisIdOriginal: '3016-3',
isWaitingForResults: false,
analysisName: 'TSH',
results: {
nestedElements: [],
unit: 'mIU/l',
normLower: 0.4,
normUpper: 4,
normStatus: 0,
responseTime: '2024-02-29T10:49:02+00:00',
responseValue: 1.27,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '3016-3',
},
},
{
analysisIdOriginal: '22664-7',
isWaitingForResults: false,
analysisName: 'Uurea',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 3.2,
normUpper: 7.4,
normStatus: 0,
responseTime: '2024-02-29T10:19:11+00:00',
responseValue: 6.4,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '22664-7',
},
},
{
analysisIdOriginal: '50561-0',
isWaitingForResults: false,
analysisName: 'Valk',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 0.25,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '50561-0',
},
},
{
analysisIdOriginal: '60493-4',
isWaitingForResults: false,
analysisName: 'Vitamiin D (25-OH)',
results: {
nestedElements: [],
labComment: 'Väärtus >75 nmol/l on D-vitamiini tervislik tase',
unit: 'nmol/l',
normLower: 75,
normUpper: null,
normStatus: 0,
responseTime: '2024-02-29T10:49:22+00:00',
responseValue: 105.5,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '60493-4',
},
},
{
analysisIdOriginal: '60025-4',
isWaitingForResults: false,
analysisName: 'Urobilinogeen',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 17,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: null,
responseValueIsWithinNorm: true,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '60025-4',
},
},
],
};
const big2: AnalysisTestResponse = {
id: 3,
orderedAnalysisElements: [
{
analysisIdOriginal: '1988-5',
isWaitingForResults: false,
analysisName: 'CRP',
results: {
nestedElements: [],
unit: 'mg/L',
normLower: null,
normUpper: 5,
normStatus: 0,
responseTime: '2025-09-12T14:02:04+00:00',
responseValue: 1,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '1988-5',
},
},
{
analysisIdOriginal: '57021-8',
isWaitingForResults: false,
analysisName: 'Hemogramm',
results: {
nestedElements: [
{
status: 4,
unit: 'g/L',
normLower: 134,
normUpper: 170,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 150,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '718-7',
},
{
status: 4,
unit: '%',
normLower: 40,
normUpper: 49,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 45,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '4544-3',
},
{
status: 4,
unit: 'E9/L',
normLower: 4.1,
normUpper: 9.7,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '6690-2',
},
{
status: 4,
unit: 'E12/L',
normLower: 4.5,
normUpper: 5.7,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '789-8',
},
{
status: 4,
unit: 'fL',
normLower: 82,
normUpper: 95,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 85,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '787-2',
},
{
status: 4,
unit: 'pg',
normLower: 28,
normUpper: 33,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 30,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '785-6',
},
{
status: 4,
unit: 'g/L',
normLower: 322,
normUpper: 356,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 355,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '786-4',
},
{
status: 4,
unit: '%',
normLower: 12,
normUpper: 15,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 15,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '788-0',
},
{
status: 4,
unit: 'E9/L',
normLower: 157,
normUpper: 372,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 255,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '777-3',
},
{
status: 4,
unit: '%',
normLower: 0.18,
normUpper: 0.38,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0.2,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '51637-7',
},
{
status: 4,
unit: 'fL',
normLower: 9.2,
normUpper: 12.3,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 10,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '32623-1',
},
{
status: 4,
unit: 'fL',
normLower: 10.1,
normUpper: 16.2,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 15,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '32207-3',
},
{
status: 4,
unit: 'E9/L',
normLower: 0.01,
normUpper: 0.08,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0.05,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '704-7',
},
{
status: 4,
unit: 'E9/L',
normLower: 0.02,
normUpper: 0.4,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0.05,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '711-2',
},
{
status: 4,
unit: 'E9/L',
normLower: 1.9,
normUpper: 6.7,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '751-8',
},
{
status: 4,
unit: 'E9/L',
normLower: 0.24,
normUpper: 0.8,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0.5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '742-7',
},
{
status: 4,
unit: 'E9/L',
normLower: 1.3,
normUpper: 3.1,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 1.5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '731-0',
},
{
status: 4,
unit: 'E9/L',
normLower: 0,
normUpper: 0.03,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 0,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '51584-1',
},
{
status: 4,
unit: '%',
normLower: 0,
normUpper: 0.5,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '38518-7',
},
{
status: 4,
unit: 'E9/L',
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0,
normUpper: null,
normLower: null,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '771-6',
},
{
status: 4,
unit: '/100WBC',
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0,
normUpper: null,
normLower: null,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '58413-6',
},
],
unit: null,
normLower: null,
normUpper: null,
normStatus: null,
responseTime: null,
responseValue: null,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: false,
responseValueIsWithinNorm: false,
status: 4,
analysisElementOriginalId: '57021-8',
},
},
{
analysisIdOriginal: '43583-4',
isWaitingForResults: false,
analysisName: 'Lipoproteiin a',
results: {
nestedElements: [],
labComment: 'Kliendi soovil analüüs tühistatud.',
unit: null,
normLower: null,
normUpper: null,
normStatus: null,
responseTime: null,
responseValue: null,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: false,
responseValueIsWithinNorm: false,
status: 5,
analysisElementOriginalId: '43583-4',
},
},
{
analysisIdOriginal: '60493-4',
isWaitingForResults: false,
analysisName: 'Vitamiin D (25-OH)',
results: {
nestedElements: [],
labComment:
'Väärtus vahemikus 30-49.9 nmol/L on D-vitamiini ebapiisav tase.',
unit: 'nmol/L',
normLower: 75,
normUpper: null,
normStatus: 1,
responseTime: '2025-09-12T14:02:04+00:00',
responseValue: 30,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
status: 4,
analysisElementOriginalId: '60493-4',
},
},
],
};
export const analysisResponses: AnalysisTestResponse[] = [empty1, big1, big2];

View File

@@ -1,20 +1,12 @@
import { redirect } from 'next/navigation';
import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header'; import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header';
import { loadCategory } from '@/app/home/(user)/_lib/server/load-category'; import { loadCategory } from '@/app/home/(user)/_lib/server/load-category';
import { pathsConfig } from '@kit/shared/config';
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs'; import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import BookingContainer from '~/home/(user)/_components/booking/booking-container';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();
@@ -25,30 +17,9 @@ export const generateMetadata = async () => {
}; };
}; };
async function BookingHandlePage({ async function BookingHandlePage({ params }: { params: { handle: string } }) {
params, const handle = await params.handle;
}: {
params: Promise<{ handle: string }>;
}) {
const { handle } = await params;
const { category } = await loadCategory({ handle }); const { category } = await loadCategory({ handle });
const { account } = await loadCurrentUserAccount();
if (!category) {
return <div>Category not found</div>;
}
if (!account) {
return redirect(pathsConfig.auth.signIn);
}
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_TTO_SERVICE_BOOKING,
extraData: {
handle,
},
});
return ( return (
<> <>
@@ -59,10 +30,10 @@ async function BookingHandlePage({
/> />
<HomeLayoutPageHeader <HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />} title={<Trans i18nKey={'booking:title'} />}
description="" description={<Trans i18nKey={'booking:description'} />}
/> />
<BookingContainer category={category} /> <PageBody></PageBody>
</> </>
); );
} }

View File

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

View File

@@ -1,15 +1,91 @@
'use server'; 'use server';
import { MontonioOrderToken } from '@/app/home/(user)/_components/cart/types';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { retrieveCart } from '@lib/data/cart';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { z } from "zod";
import { MontonioOrderToken } from "@/app/home/(user)/_components/cart/types";
import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-account";
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 { 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 { handlePlaceOrder } from '../../../_lib/server/cart-actions'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
const MONTONIO_PAID_STATUS = 'PAID'; const MONTONIO_PAID_STATUS = 'PAID';
async function decodeOrderToken(orderToken: string) { const env = () => z
.object({
emailSender: z
.string({
error: 'EMAIL_SENDER is required',
})
.min(1),
siteUrl: z
.string({
error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
})
.parse({
emailSender: process.env.EMAIL_SENDER,
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
});
const sendEmail = async ({
account,
email,
analysisPackageName,
personName,
partnerLocationName,
language,
}: {
account: AccountWithParams,
email: string,
analysisPackageName: string,
personName: string,
partnerLocationName: string,
language: string,
}) => {
const client = getSupabaseServerAdminClient();
try {
const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const { html, subject } = await renderSynlabAnalysisPackageEmail({
analysisPackageName,
personName,
partnerLocationName,
language,
});
await mailer
.sendEmail({
from: env().emailSender,
to: email,
subject,
html,
})
.catch((error) => {
throw new Error(`Failed to send email, message=${error}`);
});
await createNotificationsApi(client)
.createNotification({
account_id: account.id,
body: html,
});
} catch (error) {
throw new Error(`Failed to send email, message=${error}`);
}
}
export async function processMontonioCallback(orderToken: string) {
const { language } = await createI18nServerInstance();
const secretKey = process.env.MONTONIO_SECRET_KEY as string; const secretKey = process.env.MONTONIO_SECRET_KEY as string;
const decoded = jwt.verify(orderToken, secretKey, { const decoded = jwt.verify(orderToken, secretKey, {
@@ -17,37 +93,61 @@ async function decodeOrderToken(orderToken: string) {
}) as MontonioOrderToken; }) as MontonioOrderToken;
if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) { if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) {
throw new Error('Payment not successful'); throw new Error("Payment not successful");
} }
return decoded; const account = await loadCurrentUserAccount();
}
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;
}
export async function processMontonioCallback(orderToken: string) {
const { account } = await loadCurrentUserAccount();
if (!account) { if (!account) {
throw new Error('Account not found in context'); throw new Error("Account not found in context");
} }
try { try {
const decoded = await decodeOrderToken(orderToken); const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
const cart = await getCartByOrderToken(decoded); if (!cartId) {
const result = await handlePlaceOrder({ cart }); throw new Error("Cart ID not found");
return result; }
const cart = await retrieveCart(cartId);
if (!cart) {
throw new Error("Cart not found");
}
const medusaOrder = await placeOrder(cartId, { 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);
const orderResult = {
medusaOrderId: medusaOrder.id,
email: medusaOrder.email,
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
analysisPackageName: analysisPackageOrderItem?.title ?? '',
orderedAnalysisElements,
};
const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult;
const personName = account.name;
if (email && analysisPackageName) {
try {
await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language });
} catch (error) {
console.error("Failed to send email", error);
}
} else {
// @TODO send email for separate analyses
console.error("Missing email or analysisPackageName", orderResult);
}
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
return { success: true, orderId };
} catch (error) { } catch (error) {
console.error('Failed to place order', error); console.error("Failed to place order", error);
throw new Error(`Failed to place order, message=${error}`); throw new Error(`Failed to place order, message=${error}`);
} }
} }

View File

@@ -1,15 +1,10 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { processMontonioCallback } from './actions'; import { processMontonioCallback } from './actions';
export default function MontonioCallbackClient({ export default function MontonioCallbackClient({ orderToken, error }: {
orderToken,
error,
}: {
orderToken?: string; orderToken?: string;
error?: string; error?: string;
}) { }) {
@@ -34,17 +29,10 @@ export default function MontonioCallbackClient({
setHasProcessed(true); setHasProcessed(true);
try { try {
const result = await processMontonioCallback(orderToken); const { orderId } = await processMontonioCallback(orderToken);
if (result.success) { router.push(`/home/order/${orderId}/confirmed`);
return router.push(`/home/order/${result.orderId}/confirmed`);
}
if (result.failedServiceBookings?.length) {
router.push(
`/home/cart/montonio-callback/error?reasonFailed=${result.failedServiceBookings.map(({ reason }) => reason).join(',')}`,
);
}
} catch (error) { } catch (error) {
console.error('Failed to place order', error); console.error("Failed to place order", error);
router.push('/home/cart/montonio-callback/error'); router.push('/home/cart/montonio-callback/error');
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
@@ -55,9 +43,9 @@ export default function MontonioCallbackClient({
}, [orderToken, error, router, hasProcessed, isProcessing]); }, [orderToken, error, router, hasProcessed, isProcessing]);
return ( return (
<div className="mt-10 flex min-h-screen justify-center"> <div className="flex mt-10 justify-center min-h-screen">
<div className="text-center"> <div className="text-center">
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
</div> </div>
</div> </div>
); );

View File

@@ -1,15 +1,11 @@
import { use } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { toArray } from '@kit/shared/utils'; import { Trans } from '@kit/ui/trans';
import { Button } from '@kit/ui/button';
import { Alert, AlertDescription } from '@kit/ui/shadcn/alert'; import { Alert, AlertDescription } from '@kit/ui/shadcn/alert';
import { AlertTitle } from '@kit/ui/shadcn/alert'; import { AlertTitle } from '@kit/ui/shadcn/alert';
import { Trans } from '@kit/ui/trans'; import { Button } from '@kit/ui/button';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -19,15 +15,7 @@ export async function generateMetadata() {
}; };
} }
export default async function MontonioCheckoutCallbackErrorPage({ export default async function MontonioCheckoutCallbackErrorPage() {
searchParams,
}: {
searchParams: Promise<{ reasonFailed: string }>;
}) {
const params = await searchParams;
const failedBookingData: string[] = toArray(params.reasonFailed?.split(','));
return ( return (
<div className={'flex h-full flex-1 flex-col'}> <div className={'flex h-full flex-1 flex-col'}>
<PageHeader title={<Trans i18nKey="cart:montonioCallback.title" />} /> <PageHeader title={<Trans i18nKey="cart:montonioCallback.title" />} />
@@ -39,15 +27,9 @@ export default async function MontonioCheckoutCallbackErrorPage({
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
{failedBookingData.length ? ( <p>
failedBookingData.map((failureReason, index) => (
<p key={index}>
<Trans i18nKey={`checkout.error.${failureReason}`} />
</p>
))
) : (
<Trans i18nKey={'checkout.error.description'} /> <Trans i18nKey={'checkout.error.description'} />
)} </p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View File

@@ -1,14 +1,12 @@
import MontonioCallbackClient from './client-component'; import MontonioCallbackClient from './client-component';
export default async function MontonioCallbackPage({ export default async function MontonioCallbackPage({ searchParams }: {
searchParams,
}: {
searchParams: Promise<{ searchParams: Promise<{
'order-token'?: string; 'order-token'?: string;
}>; }>;
}) { }) {
const orderToken = (await searchParams)['order-token']; const orderToken = (await searchParams)['order-token'];
if (!orderToken) { if (!orderToken) {
return <MontonioCallbackClient error="Order token is missing" />; return <MontonioCallbackClient error="Order token is missing" />;
} }

View File

@@ -1,19 +1,13 @@
import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { notFound } from 'next/navigation';
import { retrieveCart } from '@lib/data/cart'; import { retrieveCart } from '@lib/data/cart';
import { listProductTypes } from '@lib/data/products';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getCartReservations } from '~/lib/services/reservation.service';
import { findProductTypeIdByHandle } from '~/lib/utils';
import Cart from '../../_components/cart'; import Cart from '../../_components/cart';
import { listProductTypes } from '@lib/data/products';
import CartTimer from '../../_components/cart/cart-timer'; import CartTimer from '../../_components/cart/cart-timer';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account'; import { Trans } from '@kit/ui/trans';
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
import { EnrichedCartItem } from '../../_components/cart/types';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -23,70 +17,36 @@ export async function generateMetadata() {
}; };
} }
async function CartPage() { export default async function CartPage() {
const [ const cart = await retrieveCart().catch((error) => {
cart, console.error(error);
{ productTypes }, return notFound();
{ account }, });
] = await Promise.all([
retrieveCart(),
listProductTypes(),
loadCurrentUserAccount(),
]);
if (!account) { const { productTypes } = await listProductTypes();
return null; const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
} const synlabAnalysisType = productTypes.find(({ metadata }) => metadata?.handle === 'synlab-analysis');
const synlabAnalyses = analysisPackagesType && synlabAnalysisType && cart?.items
? cart.items.filter((item) => {
const productTypeId = item.product?.type_id;
if (!productTypeId) {
return false;
}
return [analysisPackagesType.id, synlabAnalysisType.id].includes(productTypeId);
})
: [];
const ttoServiceItems = cart?.items?.filter((item) => !synlabAnalyses.some((analysis) => analysis.id === item.id)) ?? [];
const balanceSummary = await new AccountBalanceService().getBalanceSummary(account.id); const otherItemsSorted = ttoServiceItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
const synlabAnalysisTypeId = findProductTypeIdByHandle(
productTypes,
'synlab-analysis',
);
const analysisPackagesTypeId = findProductTypeIdByHandle(
productTypes,
'analysis-packages',
);
const synlabAnalyses =
analysisPackagesTypeId && synlabAnalysisTypeId && cart?.items
? cart.items.filter((item) => {
const productTypeId = item.product?.type_id;
if (!productTypeId) {
return false;
}
return [analysisPackagesTypeId, synlabAnalysisTypeId].includes(
productTypeId,
);
})
: [];
let ttoServiceItems: EnrichedCartItem[] = [];
if (cart?.items?.length) {
ttoServiceItems = await getCartReservations(cart);
}
const otherItemsSorted = ttoServiceItems.sort((a, b) =>
(a.updated_at ?? '') > (b.updated_at ?? '') ? -1 : 1,
);
const item = otherItemsSorted[0]; const item = otherItemsSorted[0];
const isTimerShown = const isTimerShown = ttoServiceItems.length > 0 && !!item && !!item.updated_at;
ttoServiceItems.length > 0 && !!item && !!item.updated_at;
return ( return (
<PageBody> <PageBody>
<PageHeader title={<Trans i18nKey="cart:title" />}> <PageHeader title={<Trans i18nKey="cart:title" />}>
{isTimerShown && <CartTimer cartItem={item} />} {isTimerShown && <CartTimer cartItem={item} />}
</PageHeader> </PageHeader>
<Cart <Cart cart={cart} synlabAnalyses={synlabAnalyses} ttoServiceItems={ttoServiceItems} />
accountId={account.id}
cart={cart}
synlabAnalyses={synlabAnalyses}
ttoServiceItems={ttoServiceItems}
balanceSummary={balanceSummary}
/>
</PageBody> </PageBody>
); );
} }
export default withI18n(CartPage);

View File

@@ -1,13 +1,13 @@
import { Scale } from 'lucide-react'; import { Scale } from 'lucide-react';
import SelectAnalysisPackages from '@kit/shared/components/select-analysis-packages';
import { Button } from '@kit/ui/button';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import SelectAnalysisPackages from '@kit/shared/components/select-analysis-packages';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import ComparePackagesModal from '../../_components/compare-packages-modal'; import ComparePackagesModal from '../../_components/compare-packages-modal';
import { loadAnalysisPackages } from '../../_lib/server/load-analysis-packages'; import { loadAnalysisPackages } from '../../_lib/server/load-analysis-packages';
@@ -21,8 +21,7 @@ export const generateMetadata = async () => {
}; };
async function OrderAnalysisPackagePage() { async function OrderAnalysisPackagePage() {
const { analysisPackageElements, analysisPackages, countryCode } = const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages();
await loadAnalysisPackages();
return ( return (
<PageBody> <PageBody>
@@ -41,10 +40,7 @@ async function OrderAnalysisPackagePage() {
} }
/> />
</div> </div>
<SelectAnalysisPackages <SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
analysisPackages={analysisPackages}
countryCode={countryCode}
/>
</PageBody> </PageBody>
); );
} }

View File

@@ -1,16 +1,12 @@
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header'; import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import OrderAnalysesCards from '../../_components/order-analyses-cards';
import { loadAnalyses } from '../../_lib/server/load-analyses'; import { loadAnalyses } from '../../_lib/server/load-analyses';
import OrderAnalysesCards from '../../_components/order-analyses-cards';
import { createPageViewLog, PageViewAction } from '~/lib/services/audit/pageView.service';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account'; import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
export const generateMetadata = async () => { export const generateMetadata = async () => {
@@ -22,13 +18,13 @@ export const generateMetadata = async () => {
}; };
async function OrderAnalysisPage() { async function OrderAnalysisPage() {
const { account } = await loadCurrentUserAccount(); const account = await loadCurrentUserAccount();
if (!account) { if (!account) {
throw new Error('Account not found'); throw new Error('Account not found');
} }
const { analyses, countryCode } = await loadAnalyses(); const { analyses, countryCode } = await loadAnalyses();
await createPageViewLog({ await createPageViewLog({
accountId: account.id, accountId: account.id,
action: PageViewAction.VIEW_ORDER_ANALYSIS, action: PageViewAction.VIEW_ORDER_ANALYSIS,

View File

@@ -1,9 +1,8 @@
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header'; import { HomeLayoutPageHeader } from '../../_components/home-page-header';
export const generateMetadata = async () => { export const generateMetadata = async () => {
@@ -22,9 +21,6 @@ async function OrderHealthAnalysisPage() {
description={<Trans i18nKey={'order-health-analysis:description'} />} description={<Trans i18nKey={'order-health-analysis:description'} />}
/> />
<PageBody> <PageBody>
<h4 className="mt-8">
<Trans i18nKey="booking:noCategories" />
</h4>
</PageBody> </PageBody>
</> </>
); );

View File

@@ -1,81 +0,0 @@
'use client';
import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
import OrderDetails from '@/app/home/(user)/_components/order/order-details';
import OrderItems from '@/app/home/(user)/_components/order/order-items';
import Divider from '@modules/common/components/divider';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { StoreOrder } from '@medusajs/types';
import { AnalysisOrder } from '~/lib/types/analysis-order';
import { useEffect, useRef, useState } from 'react';
import { retrieveOrder } from '@lib/data/orders';
import { GlobalLoader } from '@kit/ui/makerkit/global-loader';
function OrderConfirmedLoadingWrapper({
medusaOrder: initialMedusaOrder,
order,
}: {
medusaOrder: StoreOrder;
order: AnalysisOrder;
}) {
const [medusaOrder, setMedusaOrder] = useState<StoreOrder>(initialMedusaOrder);
const fetchingRef = useRef(false);
const paymentStatus = medusaOrder.payment_status;
const medusaOrderId = order.medusa_order_id;
useEffect(() => {
if (paymentStatus === 'captured') {
return;
}
const interval = setInterval(async () => {
if (fetchingRef.current) {
return;
}
fetchingRef.current = true;
const medusaOrder = await retrieveOrder(medusaOrderId, false);
fetchingRef.current = false;
setMedusaOrder(medusaOrder);
}, 2_000);
return () => clearInterval(interval);
}, [paymentStatus, medusaOrderId]);
const isPaid = paymentStatus === 'captured';
if (!isPaid) {
return (
<PageBody>
<div className="flex flex-col justify-start items-center h-full pt-[10vh]">
<div>
<GlobalLoader />
</div>
<h4 className="text-center">
<Trans i18nKey="cart:orderConfirmed.paymentConfirmationLoading" />
</h4>
</div>
</PageBody>
);
}
return (
<PageBody>
<PageHeader title={<Trans i18nKey="cart:orderConfirmed.title" />} />
<Divider />
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 gap-y-6 lg:px-4">
<OrderDetails order={order} />
<Divider />
<OrderItems medusaOrder={medusaOrder} />
<CartTotals medusaOrder={medusaOrder} />
</div>
</PageBody>
);
}
export default OrderConfirmedLoadingWrapper;

View File

@@ -1,13 +1,18 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { PageBody, PageHeader } from '@kit/ui/page';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { retrieveOrder } from '@lib/data/orders'; import { withI18n } from '~/lib/i18n/with-i18n';
import { getOrder } from '~/lib/services/order.service';
import { retrieveOrder } from '@lib/data/orders';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { withI18n } from '~/lib/i18n/with-i18n'; import Divider from "@modules/common/components/divider"
import { getAnalysisOrder } from '~/lib/services/order.service'; import OrderDetails from '@/app/home/(user)/_components/order/order-details';
import OrderConfirmedLoadingWrapper from './order-confirmed-loading-wrapper'; import OrderItems from '@/app/home/(user)/_components/order/order-items';
import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
import { Trans } from '@kit/ui/trans';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -22,21 +27,28 @@ async function OrderConfirmedPage(props: {
}) { }) {
const params = await props.params; const params = await props.params;
const order = await getAnalysisOrder({ const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null);
analysisOrderId: Number(params.orderId),
}).catch(() => null);
if (!order) { if (!order) {
redirect(pathsConfig.app.myOrders); redirect(pathsConfig.app.myOrders);
} }
const medusaOrder = await retrieveOrder(order.medusa_order_id).catch( const medusaOrder = await retrieveOrder(order.medusa_order_id).catch(() => null);
() => null,
);
if (!medusaOrder) { if (!medusaOrder) {
redirect(pathsConfig.app.myOrders); redirect(pathsConfig.app.myOrders);
} }
return <OrderConfirmedLoadingWrapper medusaOrder={medusaOrder} order={order} />; return (
<PageBody>
<PageHeader title={<Trans i18nKey="cart:orderConfirmed.title" />} />
<Divider />
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4 gap-y-6">
<OrderDetails order={order} />
<Divider />
<OrderItems medusaOrder={medusaOrder} />
<CartTotals medusaOrder={medusaOrder} />
</div>
</PageBody>
);
} }
export default withI18n(OrderConfirmedPage); export default withI18n(OrderConfirmedPage);

View File

@@ -1,19 +1,19 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { PageBody, PageHeader } from '@kit/ui/page';
import CartTotals from '@/app/home/(user)/_components/order/cart-totals'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getOrder } from '~/lib/services/order.service';
import { retrieveOrder } from '@lib/data/orders';
import { pathsConfig } from '@kit/shared/config';
import Divider from "@modules/common/components/divider"
import OrderDetails from '@/app/home/(user)/_components/order/order-details'; import OrderDetails from '@/app/home/(user)/_components/order/order-details';
import OrderItems from '@/app/home/(user)/_components/order/order-items'; import OrderItems from '@/app/home/(user)/_components/order/order-items';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
import { retrieveOrder } from '@lib/data/orders';
import Divider from '@modules/common/components/divider';
import { pathsConfig } from '@kit/shared/config';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getAnalysisOrder } from '~/lib/services/order.service';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -26,7 +26,13 @@ async function OrderConfirmedPage(props: {
params: Promise<{ orderId: string }>; params: Promise<{ orderId: string }>;
}) { }) {
const params = await props.params; const params = await props.params;
const medusaOrder = await retrieveOrder(params.orderId).catch(() => null);
const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null);
if (!order) {
redirect(pathsConfig.app.myOrders);
}
const medusaOrder = await retrieveOrder(order.medusa_order_id).catch(() => null);
if (!medusaOrder) { if (!medusaOrder) {
redirect(pathsConfig.app.myOrders); redirect(pathsConfig.app.myOrders);
} }
@@ -35,13 +41,8 @@ async function OrderConfirmedPage(props: {
<PageBody> <PageBody>
<PageHeader title={<Trans i18nKey="cart:order.title" />} /> <PageHeader title={<Trans i18nKey="cart:order.title" />} />
<Divider /> <Divider />
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 gap-y-6 lg:px-4"> <div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4 gap-y-6">
<OrderDetails <OrderDetails order={order} />
order={{
id: medusaOrder.id,
created_at: medusaOrder.created_at,
}}
/>
<Divider /> <Divider />
<OrderItems medusaOrder={medusaOrder} /> <OrderItems medusaOrder={medusaOrder} />
<CartTotals medusaOrder={medusaOrder} /> <CartTotals medusaOrder={medusaOrder} />

View File

@@ -1,24 +1,18 @@
import React from 'react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { listOrders } from '~/medusa/lib/data/orders';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { listProductTypes } from '@lib/data/products'; import { listProductTypes } from '@lib/data/products';
import { Divider } from '@medusajs/ui';
import { pathsConfig } from '@kit/shared/config';
import { PageBody } from '@kit/ui/makerkit/page'; import { PageBody } from '@kit/ui/makerkit/page';
import { pathsConfig } from '@kit/shared/config';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getAnalysisOrders, getTtoOrders } from '~/lib/services/order.service';
import { findProductTypeIdByHandle } from '~/lib/utils';
import { listOrders } from '~/medusa/lib/data/orders';
import { HomeLayoutPageHeader } from '../../_components/home-page-header'; import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import { getAnalysisOrders } from '~/lib/services/order.service';
import OrderBlock from '../../_components/orders/order-block'; import OrderBlock from '../../_components/orders/order-block';
import React from 'react';
const ORDERS_LIMIT = 50; import { Divider } from '@medusajs/ui';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -29,25 +23,15 @@ export async function generateMetadata() {
} }
async function OrdersPage() { async function OrdersPage() {
const [medusaOrders, analysisOrders, ttoOrders, { productTypes }] = await Promise.all([ const medusaOrders = await listOrders();
listOrders(ORDERS_LIMIT), const analysisOrders = await getAnalysisOrders();
getAnalysisOrders(), const { productTypes } = await listProductTypes();
getTtoOrders(),
listProductTypes(),
]);
if (!medusaOrders || !productTypes || !ttoOrders) { if (!medusaOrders || !productTypes) {
redirect(pathsConfig.auth.signIn); redirect(pathsConfig.auth.signIn);
} }
const analysisPackagesTypeId = findProductTypeIdByHandle( const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages')!;
productTypes,
'analysis-package',
);
const ttoServiceTypeId = findProductTypeIdByHandle(
productTypes,
'tto-service',
);
return ( return (
<> <>
@@ -56,49 +40,27 @@ async function OrdersPage() {
description={<Trans i18nKey={'orders:description'} />} description={<Trans i18nKey={'orders:description'} />}
/> />
<PageBody> <PageBody>
{medusaOrders.map((medusaOrder) => { {analysisOrders.map((analysisOrder) => {
const analysisOrder = analysisOrders.find( const medusaOrder = medusaOrders.find(({ id }) => id === analysisOrder.medusa_order_id);
({ medusa_order_id }) => medusa_order_id === medusaOrder.id,
);
if (!medusaOrder) { if (!medusaOrder) {
return null; return null;
} }
const medusaOrderItems = medusaOrder.items || []; const medusaOrderItems = medusaOrder.items || [];
const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter( const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter((item) => item.product_type_id === analysisPackagesType?.id);
(item) => item.product_type_id === analysisPackagesTypeId, const medusaOrderItemsOther = medusaOrderItems.filter((item) => item.product_type_id !== analysisPackagesType?.id);
);
const medusaOrderItemsTtoServices = medusaOrderItems.filter(
(item) => item.product_type_id === ttoServiceTypeId,
);
const medusaOrderItemsOther = medusaOrderItems.filter(
(item) =>
!item.product_type_id ||
![analysisPackagesTypeId, ttoServiceTypeId].includes(
item.product_type_id,
),
);
return ( return (
<React.Fragment key={medusaOrder.id}> <React.Fragment key={analysisOrder.id}>
<Divider className="my-6" /> <Divider className="my-6" />
<OrderBlock <OrderBlock
medusaOrderId={medusaOrder.id}
analysisOrder={analysisOrder} analysisOrder={analysisOrder}
medusaOrderStatus={medusaOrder.status}
itemsAnalysisPackage={medusaOrderItemsAnalysisPackages} itemsAnalysisPackage={medusaOrderItemsAnalysisPackages}
itemsTtoService={medusaOrderItemsTtoServices}
itemsOther={medusaOrderItemsOther} itemsOther={medusaOrderItemsOther}
/> />
</React.Fragment> </React.Fragment>
); )
})} })}
{analysisOrders.length === 0 && ttoOrders.length === 0 && (
<h5 className="mt-6">
<Trans i18nKey="orders:noOrders" />
</h5>
)}
</PageBody> </PageBody>
</> </>
); );

View File

@@ -1,22 +1,17 @@
import { Suspense } from 'react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { toTitleCase } from '@/lib/utils'; import { toTitleCase } from '@/lib/utils';
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { createUserAnalysesApi } from '@kit/user-analyses/api';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import Dashboard from '../_components/dashboard'; import Dashboard from '../_components/dashboard';
import DashboardCards from '../_components/dashboard-cards'; import DashboardCards from '../_components/dashboard-cards';
import Recommendations from '../_components/recommendations';
import RecommendationsSkeleton from '../_components/recommendations-skeleton';
import { isValidOpenAiEnv } from '../_lib/server/is-valid-open-ai-env';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
export const generateMetadata = async () => { export const generateMetadata = async () => {
@@ -31,8 +26,8 @@ export const generateMetadata = async () => {
async function UserHomePage() { async function UserHomePage() {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { account } = await loadCurrentUserAccount(); const account = await loadCurrentUserAccount();
const api = createUserAnalysesApi(client); const api = createAccountsApi(client);
const bmiThresholds = await api.fetchBmiThresholds(); const bmiThresholds = await api.fetchBmiThresholds();
if (!account) { if (!account) {
@@ -53,16 +48,6 @@ async function UserHomePage() {
/> />
<PageBody> <PageBody>
<Dashboard account={account} bmiThresholds={bmiThresholds} /> <Dashboard account={account} bmiThresholds={bmiThresholds} />
{(await isValidOpenAiEnv()) && (
<>
<h4>
<Trans i18nKey="dashboard:recommendations.title" />
</h4>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations account={account} />
</Suspense>
</>
)}
</PageBody> </PageBody>
</> </>
); );

View File

@@ -1,40 +0,0 @@
'use client';
import { isBefore, isSameDay } from 'date-fns';
import { uniq } from 'lodash';
import { Calendar } from '@kit/ui/shadcn/calendar';
import { Card } from '@kit/ui/shadcn/card';
import { cn } from '@kit/ui/utils';
import { useBooking } from './booking.provider';
export default function BookingCalendar() {
const { selectedDate, setSelectedDate, isLoadingTimeSlots, timeSlots } =
useBooking();
const availableDates = uniq(timeSlots?.map((timeSlot) => timeSlot.StartTime));
return (
<Card className="mb-4">
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
disabled={(date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return (
isBefore(date, today) ||
!availableDates.some((dateWithBooking) =>
isSameDay(date, dateWithBooking),
)
);
}}
className={cn('rounded-md border', {
'pointer-events-none rounded-md border opacity-50':
isLoadingTimeSlots,
})}
/>
</Card>
);
}

View File

@@ -1,49 +0,0 @@
'use client';
import { StoreProduct } from '@medusajs/types';
import { Trans } from '@kit/ui/trans';
import { EnrichedCartItem } from '../cart/types';
import BookingCalendar from './booking-calendar';
import { BookingProvider } from './booking.provider';
import LocationSelector from './location-selector';
import ServiceSelector from './service-selector';
import TimeSlots from './time-slots';
const BookingContainer = ({
category,
cartItem,
onComplete,
}: {
category: { products: StoreProduct[]; countryCode: string };
cartItem?: EnrichedCartItem;
onComplete?: () => void;
}) => {
const products = cartItem?.product ? [cartItem.product] : category.products;
if (!cartItem || !products?.length) {
<p>
<Trans i18nKey="booking:noProducts" />
</p>;
}
return (
<BookingProvider category={{ products }} service={cartItem?.product}>
<div className="xs:flex-row flex max-h-full flex-col gap-6">
<div className="flex flex-col">
<ServiceSelector products={products} />
<BookingCalendar />
<LocationSelector />
</div>
<TimeSlots
countryCode={category.countryCode}
cartItem={cartItem}
onComplete={onComplete}
/>
</div>
</BookingProvider>
);
};
export default BookingContainer;

View File

@@ -1,77 +0,0 @@
import { createContext } from 'react';
import { StoreProduct } from '@medusajs/types';
import { noop } from 'lodash';
import { Tables } from '@kit/supabase/database';
export type Location = Tables<
{ schema: 'medreport' },
'connected_online_locations'
>;
export type TimeSlotResponse = {
timeSlots: TimeSlot[];
locations: Location[];
};
export type TimeSlot = {
ClinicID: number;
LocationID: number;
UserID: number;
SyncUserID: number;
ServiceID: number;
HKServiceID: number;
StartTime: Date;
EndTime: Date;
PayorCode: string;
serviceProvider?: ServiceProvider;
syncedService?: SyncedService;
} & { location?: Location };
export type ServiceProvider = {
name: string;
id: number;
jobTitleEn: string | null;
jobTitleEt: string | null;
jobTitleRu: string | null;
clinicId: number;
};
export type SyncedService = Tables<
{ schema: 'medreport' },
'connected_online_services'
> & {
providerClinic: ProviderClinic;
};
export type ProviderClinic = Tables<
{ schema: 'medreport' },
'connected_online_providers'
> & { locations: Location[] };
const BookingContext = createContext<{
timeSlots: TimeSlot[] | null;
selectedService: StoreProduct | null;
locations: Location[] | null;
selectedLocationId: number | null;
selectedDate?: Date;
isLoadingTimeSlots?: boolean;
setSelectedService: (selectedService: StoreProduct | null) => void;
setSelectedLocationId: (selectedLocationId: number | null) => void;
updateTimeSlots: (serviceIds: number[]) => Promise<void>;
setSelectedDate: (selectedDate?: Date) => void;
}>({
timeSlots: null,
selectedService: null,
locations: null,
selectedLocationId: null,
selectedDate: new Date(),
isLoadingTimeSlots: false,
setSelectedService: (_) => _,
setSelectedLocationId: (_) => _,
updateTimeSlots: async (_) => noop(),
setSelectedDate: (_) => _,
});
export { BookingContext };

Some files were not shown because too many files have changed in this diff Show More