5 Commits

Author SHA1 Message Date
231b9d8dc4 test5 2025-09-01 23:56:51 +03:00
c9677d77e3 test4 2025-09-01 23:48:11 +03:00
9bfa255735 test3 2025-09-01 23:45:23 +03:00
37920a158a test2 2025-09-01 23:38:01 +03:00
b203631c63 test 2025-09-01 23:36:41 +03:00
102 changed files with 1048 additions and 2222 deletions

2
.env
View File

@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a" NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
# AUTH # AUTH
NEXT_PUBLIC_AUTH_PASSWORD=false NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY= NEXT_PUBLIC_CAPTCHA_SITE_KEY=

View File

@@ -45,12 +45,6 @@ MEDIPOST_PASSWORD=SRB48HZMV
MEDIPOST_RECIPIENT=trvurgtst MEDIPOST_RECIPIENT=trvurgtst
MEDIPOST_MESSAGE_SENDER=trvurgtst MEDIPOST_MESSAGE_SENDER=trvurgtst
MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
MEDIPOST_USER=medreport
MEDIPOST_PASSWORD=85MXFFDB7
MEDIPOST_RECIPIENT=HTI
MEDIPOST_MESSAGE_SENDER=medreport
### TEST.MEDREPORT.ee ### ### TEST.MEDREPORT.ee ###
DB_PASSWORD=T#u-$M7%RjbA@L@ DB_PASSWORD=T#u-$M7%RjbA@L@
@@ -88,5 +82,5 @@ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhY
SUPABASE_AUTH_CLIENT_ID=supabase SUPABASE_AUTH_CLIENT_ID=supabase
SUPABASE_AUTH_KEYCLOAK_SECRET=Gl394GjizClhQl06KFeoFyZ7ZbPamG5I SUPABASE_AUTH_KEYCLOAK_SECRET=Gl394GjizClhQl06KFeoFyZ7ZbPamG5I
SUPABASE_AUTH_KEYCLOAK_URL=http://localhost:8585/realms/medreport-sandbox SUPABASE_AUTH_KEYCLOAK_URL=http://localhost:8585/realms/mrb2b
SUPABASE_AUTH_KEYCLOAK_CALLBACK_URL=http://localhost:3000/auth/callback SUPABASE_AUTH_KEYCLOAK_CALLBACK_URL=http://localhost:3000/auth/callback

View File

@@ -1,15 +0,0 @@
# PRODUCTION ENVIRONMENT VARIABLES
## DO NOT ADD VARS HERE UNLESS THEY ARE PUBLIC OR NOT SENSITIVE
## THIS ENV IS USED FOR PRODUCTION AND IS COMMITED TO THE REPO
## AVOID PLACING SENSITIVE DATA IN THIS FILE.
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://kaldvociniytdbbcxvqk.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImthbGR2b2Npbml5dGRiYmN4dnFrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYzNjQ5OTYsImV4cCI6MjA3MTk0MDk5Nn0.eixihH2KGkJZolY9FiQDicJOo2kxvXrSe6gGUCrkLo0
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

View File

@@ -21,4 +21,4 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqP
CONTACT_EMAIL=test@makerkit.dev CONTACT_EMAIL=test@makerkit.dev
SUPABASE_AUTH_KEYCLOAK_URL=https://keycloak.medreport.ee/realms/medreport-prod SUPABASE_AUTH_KEYCLOAK_URL=https://keycloak.medreport.ee/realms/mrb2b

View File

@@ -11,7 +11,6 @@ COPY packages packages
COPY tooling tooling COPY tooling tooling
COPY .env .env COPY .env .env
COPY .env.production .env.production COPY .env.production .env.production
COPY .env.staging .env.staging
# Load env file and echo a specific variable # Load env file and echo a specific variable
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true # RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
@@ -21,10 +20,13 @@ COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
# 🔍 Optional: Log key envs for debug
RUN echo "📄 .env.production contents:" && cat .env.production \
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
RUN set -a \ RUN set -a \
&& . .env \ && . .env \
&& . .env.production \ && . .env.production \
&& . .env.staging \
&& set +a \ && set +a \
&& node check-env.js \ && node check-env.js \
&& pnpm build && pnpm build
@@ -32,21 +34,18 @@ RUN set -a \
# --- Stage 2: Runtime --- # --- Stage 2: Runtime ---
FROM node:20-alpine FROM node:20-alpine
ARG APP_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=builder /app ./ COPY --from=builder /app ./
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.production contents:" && cat .env.production \
&& 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

View File

@@ -69,7 +69,7 @@ function AuthButtons() {
</div> </div>
<div className={'flex gap-x-2.5'}> <div className={'flex gap-x-2.5'}>
<Button className={'block'} asChild variant={'ghost'}> <Button className={'hidden md:block'} asChild variant={'ghost'}>
<Link href={pathsConfig.auth.signIn}> <Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth:signIn'} /> <Trans i18nKey={'auth:signIn'} />
</Link> </Link>

View File

@@ -4,15 +4,14 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { sendEmailFromTemplate } from '@/lib/services/mailer.service'; import { SubmitButton } from '@kit/shared/components/ui/submit-button';
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
import { CompanySubmitData } from '@/lib/types/company'; import { CompanySubmitData } from '@/lib/types/company';
import { companyOfferSchema } from '@/lib/validations/company-offer.schema'; import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
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 { useTranslation } from 'react-i18next';
import { renderCompanyOfferEmail } from '@kit/email-templates';
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
import { FormItem } from '@kit/ui/form'; import { FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
@@ -40,14 +39,7 @@ const CompanyOfferForm = () => {
}); });
try { try {
sendEmailFromTemplate( sendCompanyOfferEmail(data, language)
renderCompanyOfferEmail,
{
companyData: data,
language,
},
process.env.CONTACT_EMAIL!,
)
.then(() => router.push('/company-offer/success')) .then(() => router.push('/company-offer/success'))
.catch((error) => { .catch((error) => {
setIsLoading(false); setIsLoading(false);

View File

@@ -1,23 +0,0 @@
import { renderNewJobsAvailableEmail } from '@kit/email-templates';
import { getDoctorAccounts } from '~/lib/services/account.service';
import { getOpenJobAnalysisResponseIds } from '~/lib/services/doctor-jobs.service';
import { sendEmailFromTemplate } from '~/lib/services/mailer.service';
export default async function sendOpenJobsEmails() {
const analysisResponseIds = await getOpenJobAnalysisResponseIds();
const doctorAccounts = await getDoctorAccounts();
const doctorEmails: string[] = doctorAccounts
.map(({ email }) => email)
.filter((email): email is string => !!email);
await sendEmailFromTemplate(
renderNewJobsAvailableEmail,
{
language: 'et',
analysisResponseIds,
},
doctorEmails,
);
}

View File

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

View File

@@ -1,53 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import {
NotificationAction,
createNotificationLog,
} from '~/lib/services/audit/notificationEntries.service';
import loadEnv from '../handler/load-env';
import sendOpenJobsEmails from '../handler/send-open-jobs-emails';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await sendOpenJobsEmails();
console.info(
'Successfully sent out open job notification emails to doctors.',
);
await createNotificationLog({
action: NotificationAction.NEW_JOBS_ALERT,
status: 'SUCCESS',
});
return NextResponse.json(
{
message:
'Successfully sent out open job notification emails to doctors.',
},
{ status: 200 },
);
} catch (e: any) {
console.error(
'Error sending out open job notification emails to doctors.',
e,
);
await createNotificationLog({
action: NotificationAction.NEW_JOBS_ALERT,
status: 'FAIL',
comment: e?.message,
});
return NextResponse.json(
{
message: 'Failed to send out open job notification emails to doctors.',
},
{ status: 500 },
);
}
};

View File

@@ -4,7 +4,6 @@ import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { capitalize } from 'lodash';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions'; import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
@@ -23,9 +22,6 @@ 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 { 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';
@@ -61,8 +57,6 @@ export default function AnalysisView({
const { data: user } = useUser(); const { data: user } = useUser();
const languageNames = useCurrentLocaleLanguageNames();
const isInProgress = !!( const isInProgress = !!(
!!feedback?.status && !!feedback?.status &&
feedback?.doctor_user_id && feedback?.doctor_user_id &&
@@ -197,12 +191,6 @@ export default function AnalysisView({
<Trans i18nKey="doctor:email" /> <Trans i18nKey="doctor:email" />
</div> </div>
<div>{patient.email}</div> <div>{patient.email}</div>
<div className="font-bold">
<Trans i18nKey="common:language" />
</div>
<div>
{capitalize(languageNames.of(patient.preferred_locale ?? 'et'))}
</div>
</div> </div>
<div className="xs:hidden block"> <div className="xs:hidden block">
<DoctorJobSelect <DoctorJobSelect

View File

@@ -10,7 +10,6 @@ import { Eye } from 'lucide-react';
import { getResultSetName } from '@kit/doctor/lib/helpers'; import { getResultSetName } from '@kit/doctor/lib/helpers';
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema'; import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
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';
@@ -24,9 +23,7 @@ import {
TableRow, TableRow,
} from '@kit/ui/table'; } from '@kit/ui/table';
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 = [],
@@ -61,8 +58,6 @@ export default function ResultsTable({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const languageNames = useCurrentLocaleLanguageNames();
const fetchPage = async (page: number) => { const fetchPage = async (page: number) => {
startTransition(async () => { startTransition(async () => {
const result = await fetchAction({ const result = await fetchAction({
@@ -121,9 +116,6 @@ export default function ResultsTable({
<TableHead className="w-20"> <TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.resultsStatus" /> <Trans i18nKey="doctor:resultsTable.resultsStatus" />
</TableHead> </TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.language" />
</TableHead>
<TableHead className="w-20"> <TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.assignedTo" /> <Trans i18nKey="doctor:resultsTable.assignedTo" />
</TableHead> </TableHead>
@@ -187,11 +179,6 @@ export default function ResultsTable({
}} }}
/> />
</TableCell> </TableCell>
<TableCell>
{capitalize(
languageNames.of(result?.patient?.preferred_locale ?? 'et'),
)}
</TableCell>
<TableCell> <TableCell>
<DoctorJobSelect <DoctorJobSelect
doctorUserId={result.doctor?.primary_owner_user_id} doctorUserId={result.doctor?.primary_owner_user_id}

View File

@@ -18,8 +18,8 @@ async function AnalysisPage({
id: string; id: string;
}>; }>;
}) { }) {
const { id: analysisOrderId } = await params; const { id: analysisResponseId } = await params;
const analysisResultDetails = await loadResult(Number(analysisOrderId)); const analysisResultDetails = await loadResult(Number(analysisResponseId));
if (!analysisResultDetails) { if (!analysisResultDetails) {
return null; return null;
@@ -28,7 +28,7 @@ async function AnalysisPage({
if (analysisResultDetails) { if (analysisResultDetails) {
await createDoctorPageViewLog({ await createDoctorPageViewLog({
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS, action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
recordKey: analysisOrderId, recordKey: analysisResponseId,
dataOwnerUserId: analysisResultDetails.patient.userId, dataOwnerUserId: analysisResultDetails.patient.userId,
}); });
} }
@@ -50,5 +50,3 @@ async function AnalysisPage({
export default DoctorGuard(AnalysisPage); export default DoctorGuard(AnalysisPage);
const loadResult = cache(getAnalysisResultsForDoctor); const loadResult = cache(getAnalysisResultsForDoctor);

View File

@@ -1,107 +0,0 @@
import Link from 'next/link';
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import Analysis from '../_components/analysis';
export default async function AnalysisResultsPage({
params,
}: {
params: Promise<{
id: string;
}>;
}) {
const account = await loadCurrentUserAccount();
const { id: analysisResponseId } = await params;
const analysisResponse = await loadUserAnalysis(Number(analysisResponseId));
if (!account?.id || !analysisResponse) {
return null;
}
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
});
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>
<h4>
<Trans i18nKey="analysis-results:pageTitle" />
</h4>
<p className="text-muted-foreground text-sm">
{analysisResponse?.elements &&
analysisResponse.elements?.length > 0 ? (
<Trans i18nKey="analysis-results:description" />
) : (
<Trans i18nKey="analysis-results:descriptionEmpty" />
)}
</p>
</div>
<Button asChild>
<Link href={pathsConfig.app.orderAnalysisPackage}>
<Trans i18nKey="analysis-results:orderNewAnalysis" />
</Link>
</Button>
</div>
<div className="flex flex-col gap-4">
<h4>
<Trans
i18nKey="analysis-results:orderTitle"
values={{ orderNumber: analysisResponse.order.medusa_order_id }}
/>
</h4>
<h5>
<Trans
i18nKey={`orders:status.${analysisResponse.order.status}`}
/>
<ButtonTooltip
content={`${analysisResponse.order.created_at ? new Date(analysisResponse?.order?.created_at).toLocaleString() : ''}`}
className="ml-6"
/>
</h5>
</div>
{analysisResponse?.summary?.value && (
<div>
<strong>
<Trans i18nKey="account:doctorAnalysisSummary" />
</strong>
<p>{analysisResponse.summary.value}</p>
</div>
)}
<div className="flex flex-col gap-2">
{analysisResponse.elements ? (
analysisResponse.elements.map((element, index) => (
<Analysis
key={index}
analysisElement={{ analysis_name_lab: element.analysis_name }}
results={element}
/>
))
) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisElements" />
</div>
)}
</div>
</PageBody>
</>
);
}

View File

@@ -0,0 +1,131 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '@/lib/i18n/with-i18n';
import { Trans } from '@kit/ui/makerkit/trans';
import { PageBody } from '@kit/ui/page';
import { Button } from '@kit/ui/shadcn/button';
import { pathsConfig } from '@kit/shared/config';
import { getAnalysisElements } from '~/lib/services/analysis-element.service';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import { AnalysisOrder, getAnalysisOrders } from '~/lib/services/order.service';
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
import { loadUserAnalysis } from '../../_lib/server/load-user-analysis';
import Analysis from './_components/analysis';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('analysis-results:pageTitle');
return {
title,
};
};
async function AnalysisResultsPage() {
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
const analysisResponses = await loadUserAnalysis();
const analysisResponseElements = analysisResponses?.flatMap(
({ elements }) => elements,
);
const analysisOrders = await getAnalysisOrders().catch(() => null);
if (!analysisOrders) {
redirect(pathsConfig.auth.signIn);
}
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
});
const getAnalysisElementIds = (analysisOrders: AnalysisOrder[]) => [
...new Set(analysisOrders?.flatMap((order) => order.analysis_element_ids).filter(Boolean) as number[]),
];
const analysisElementIds = getAnalysisElementIds(analysisOrders);
const analysisElements = await getAnalysisElements({ ids: analysisElementIds });
return (
<PageBody className="gap-4">
<div className="mt-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0">
<div>
<h4>
<Trans i18nKey="analysis-results:pageTitle" />
</h4>
<p className="text-muted-foreground text-sm">
{analysisResponses && analysisResponses.length > 0 ? (
<Trans i18nKey="analysis-results:description" />
) : (
<Trans i18nKey="analysis-results:descriptionEmpty" />
)}
</p>
</div>
<Button asChild>
<Link href={pathsConfig.app.orderAnalysisPackage}>
<Trans i18nKey="analysis-results:orderNewAnalysis" />
</Link>
</Button>
</div>
<div className="flex flex-col gap-8">
{analysisOrders.length > 0 && analysisElements.length > 0 ? analysisOrders.map((analysisOrder) => {
const analysisResponse = analysisResponses?.find((response) => response.analysis_order_id === analysisOrder.id);
const analysisElementIds = getAnalysisElementIds([analysisOrder]);
const analysisElementsForOrder = analysisElements.filter((element) => analysisElementIds.includes(element.id));
return (
<div key={analysisOrder.id} className="flex flex-col gap-4">
<h4>
<Trans i18nKey="analysis-results:orderTitle" values={{ orderNumber: analysisOrder.medusa_order_id }} />
</h4>
<h5>
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
<ButtonTooltip
content={`${new Date(analysisOrder.created_at).toLocaleString()}`}
className="ml-6"
/>
</h5>
<div className="flex flex-col gap-2">
{analysisElementsForOrder.length > 0 ? analysisElementsForOrder.map((analysisElement) => {
const results = analysisResponse?.elements.some((element) => element.analysis_element_original_id === analysisElement.analysis_id_original)
&& analysisResponseElements?.find((element) => element.analysis_element_original_id === analysisElement.analysis_id_original);
if (!results) {
return (
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} isCancelled={analysisOrder.status === 'CANCELLED'}/>
);
}
return (
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} results={results} />
);
}) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisElements" />
</div>
)}
</div>
</div>
);
}) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisOrders" />
</div>
)}
</div>
</PageBody>
);
}
export default withI18n(AnalysisResultsPage);

View File

@@ -1,28 +1,30 @@
import Link from 'next/link';
import { ChevronRight, HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardDescription, CardFooter, CardHeader } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import {
Card,
CardHeader,
CardDescription,
CardFooter,
} from '@kit/ui/card';
import Link from 'next/link';
import { Button } from '@kit/ui/button';
import { ChevronRight, HeartPulse } from 'lucide-react';
export default function DashboardCards() { export default function DashboardCards() {
return ( return (
<div className="flex gap-4 lg:px-4"> <div className='flex gap-4 lg:px-4'>
<Card <Card
variant="gradient-success" variant="gradient-success"
className="xs:w-1/2 sm:w-auto flex w-full flex-col justify-between" className="flex flex-col justify-between"
> >
<CardHeader className="flex-row"> <CardHeader className="flex-row">
<div <div
className={ className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'}
'bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white'
}
> >
<HeartPulse className="size-4 fill-green-500" /> <HeartPulse className="size-4 fill-green-500" />
</div> </div>
<div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white"> <div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
<Link href="/home/order-analysis"> <Link href='/home/order-analysis'>
<Button size="icon" variant="outline" className="px-2 text-black"> <Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" /> <ChevronRight className="size-4 stroke-2" />
</Button> </Button>
@@ -31,10 +33,10 @@ export default function DashboardCards() {
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-start gap-2"> <CardFooter className="flex flex-col items-start gap-2">
<h5> <h5>
<Trans i18nKey="dashboard:heroCard.orderAnalysis.title" /> <Trans i18nKey='dashboard:heroCard.orderAnalysis.title' />
</h5> </h5>
<CardDescription className="text-primary"> <CardDescription className="text-primary">
<Trans i18nKey="dashboard:heroCard.orderAnalysis.description" /> <Trans i18nKey='dashboard:heroCard.orderAnalysis.description' />
</CardDescription> </CardDescription>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -166,7 +166,7 @@ export default function Dashboard({
return ( return (
<> <>
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5"> <div className="grid auto-rows-fr grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
{cards({ {cards({
gender: params?.gender, gender: params?.gender,
age: params?.age, age: params?.age,
@@ -233,11 +233,8 @@ export default function Dashboard({
index, index,
) => { ) => {
return ( return (
<div <div className="flex justify-between" key={index}>
className="flex w-full justify-between gap-3 overflow-scroll" <div className="mr-4 flex flex-row items-center gap-4">
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div <div
className={cn( className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white', 'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
@@ -246,7 +243,7 @@ export default function Dashboard({
> >
{icon} {icon}
</div> </div>
<div className="min-w-fit"> <div>
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium"> <div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title} {title}
<InfoTooltip content={tooltipContent} /> <InfoTooltip content={tooltipContent} />
@@ -256,24 +253,16 @@ export default function Dashboard({
</p> </p>
</div> </div>
</div> </div>
<div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4 min-w-fit"> <div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4">
<p className="text-sm font-medium"> {price}</p> <p className="text-sm font-medium"> {price}</p>
{href ? ( {href ? (
<Link href={href}> <Link href={href}>
<Button <Button size="sm" variant="secondary">
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText} {buttonText}
</Button> </Button>
</Link> </Link>
) : ( ) : (
<Button <Button size="sm" variant="secondary">
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText} {buttonText}
</Button> </Button>
)} )}

View File

@@ -1,16 +1,12 @@
'use client'; 'use client';
import { useMemo } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { StoreCart } from '@medusajs/types'; import { StoreCart } from '@medusajs/types';
import { Cross, LogOut, Menu, Shield, ShoppingCart } from 'lucide-react'; import { LogOut, Menu, ShoppingCart } from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
import { import {
pathsConfig, featureFlagsConfig,
personalAccountNavigationConfig, personalAccountNavigationConfig,
} from '@kit/shared/config'; } from '@kit/shared/config';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
@@ -19,6 +15,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
@@ -26,16 +23,14 @@ import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
// home imports // home imports
import { HomeAccountSelector } from '../_components/home-account-selector';
import type { UserWorkspace } from '../_lib/server/load-user-workspace'; import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { export function HomeMobileNavigation(props: {
workspace: UserWorkspace; workspace: UserWorkspace;
cart: StoreCart | null; cart: StoreCart | null;
}) { }) {
const user = props.workspace.user;
const signOut = useSignOut(); const signOut = useSignOut();
const { data: personalAccountData } = usePersonalAccountData(user.id);
const Links = personalAccountNavigationConfig.routes.map((item, index) => { const Links = personalAccountNavigationConfig.routes.map((item, index) => {
if ('children' in item) { if ('children' in item) {
@@ -56,29 +51,7 @@ export function HomeMobileNavigation(props: {
} }
}); });
const hasTotpFactor = useMemo(() => { const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const factors = user?.factors ?? [];
return factors.some(
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
);
}, [user?.factors]);
const isSuperAdmin = useMemo(() => {
const hasAdminRole =
personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin;
return hasAdminRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
const isDoctor = useMemo(() => {
const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0; const hasCartItems = cartQuantityTotal > 0;
return ( return (
@@ -88,6 +61,22 @@ export function HomeMobileNavigation(props: {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}> <DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={featureFlagsConfig.enableTeamAccounts}>
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans i18nKey={'common:yourAccounts'} />
</DropdownMenuLabel>
<HomeAccountSelector
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
collisionPadding={0}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<If condition={props.cart && hasCartItems}> <If condition={props.cart && hasCartItems}>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownLink <DropdownLink
@@ -102,41 +91,6 @@ export function HomeMobileNavigation(props: {
<DropdownMenuGroup>{Links}</DropdownMenuGroup> <DropdownMenuGroup>{Links}</DropdownMenuGroup>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.admin}
>
<Shield className={'h-5'} />
<span>Super Admin</span>
</Link>
</DropdownMenuItem>
</If>
<If condition={isDoctor}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
'flex h-full cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.doctor}
>
<Cross className={'h-5'} />
<span>
<Trans i18nKey="common:doctor" />
</span>
</Link>
</DropdownMenuItem>
</If>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} /> <SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />

View File

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

View File

@@ -1,32 +1,22 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { Trans } from '@kit/ui/trans';
import { StoreOrderLineItem } from '@medusajs/types';
import { formatDate } from 'date-fns';
import { Eye } from 'lucide-react';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { import {
Table, Table,
TableBody, TableBody,
TableCell,
TableHead, TableHead,
TableHeader,
TableRow, TableRow,
TableHeader,
TableCell,
} from '@kit/ui/table'; } from '@kit/ui/table';
import { Trans } from '@kit/ui/trans'; import { StoreOrderLineItem } from "@medusajs/types";
import { AnalysisOrder } from '~/lib/services/order.service'; import { AnalysisOrder } from '~/lib/services/order.service';
import { formatDate } from 'date-fns';
import { Eye } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { logAnalysisResultsNavigateAction } from './actions'; import { logAnalysisResultsNavigateAction } from './actions';
export default function OrderItemsTable({ export default function OrderItemsTable({ items, title, analysisOrder }: {
items,
title,
analysisOrder,
}: {
items: StoreOrderLineItem[]; items: StoreOrderLineItem[];
title: string; title: string;
analysisOrder: AnalysisOrder; analysisOrder: AnalysisOrder;
@@ -39,11 +29,11 @@ export default function OrderItemsTable({
const openAnalysisResults = async () => { const openAnalysisResults = async () => {
await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id);
router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); router.push(`/home/analysis-results`);
}; }
return ( return (
<Table className="border-separate rounded-lg border"> <Table className="rounded-lg border border-separate">
<TableHeader className="text-ui-fg-subtle txt-medium-plus"> <TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow> <TableRow>
<TableHead className="px-6"> <TableHead className="px-6">
@@ -55,14 +45,13 @@ export default function OrderItemsTable({
<TableHead className="px-6"> <TableHead className="px-6">
<Trans i18nKey="orders:table.status" /> <Trans i18nKey="orders:table.status" />
</TableHead> </TableHead>
<TableHead className="px-6"></TableHead> <TableHead className="px-6">
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items {items
.sort((a, b) => .sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1)
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((orderItem) => ( .map((orderItem) => (
<TableRow className="w-full" key={orderItem.id}> <TableRow className="w-full" key={orderItem.id}>
<TableCell className="text-left w-[100%] px-6"> <TableCell className="text-left w-[100%] px-6">
@@ -75,18 +64,23 @@ export default function OrderItemsTable({
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')} {formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell> </TableCell>
<TableCell className="min-w-[180px] px-6"> <TableCell className="px-6 min-w-[180px]">
<Trans i18nKey={`orders:status.${analysisOrder.status}`} /> <Trans i18nKey={`orders:status.${analysisOrder.status}`} />
</TableCell> </TableCell>
<TableCell className="px-6 text-right"> <TableCell className="text-right px-6">
<Button size="sm" onClick={openAnalysisResults}> <span className="flex gap-x-1 justify-end w-[30px]">
<Trans i18nKey="analysis-results:view" /> <button
</Button> className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer "
onClick={openAnalysisResults}
>
<Eye />
</button>
</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
); )
} }

View File

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

View File

@@ -6,8 +6,8 @@ import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
import type { StoreProduct } from '@medusajs/types'; import type { StoreProduct } from '@medusajs/types';
import { loadCurrentUserAccount } from './load-user-account'; import { loadCurrentUserAccount } from './load-user-account';
import { AnalysisPackageWithVariant } from '~/components/select-analysis-package';
import { AccountWithParams } from '@/packages/features/accounts/src/server/api'; import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
async function countryCodesLoader() { async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) => const countryCodes = await listRegions().then((regions) =>

View File

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

View File

@@ -1,7 +1,7 @@
import { cache } from 'react'; import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api'; import { createAccountsApi } from '@kit/accounts/api';
import { AnalysisResultDetails } from '@kit/accounts/types/accounts'; import { UserAnalysis } from '@kit/accounts/types/accounts';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>; export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
@@ -9,15 +9,14 @@ export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
/** /**
* @name loadUserAnalysis * @name loadUserAnalysis
* @description * @description
* Load the user's analysis based on id. It's a cached per-request function that fetches the user's analysis data. * Load the user's analyses. It's a cached per-request function that fetches the user workspace data.
* It can be used across the server components to load the user workspace data.
*/ */
export const loadUserAnalysis = cache(analysisLoader); export const loadUserAnalysis = cache(analysisLoader);
async function analysisLoader( async function analysisLoader(): Promise<UserAnalysis | null> {
analysisOrderId: number,
): Promise<AnalysisResultDetails | null> {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const api = createAccountsApi(client); const api = createAccountsApi(client);
return api.getUserAnalysis(analysisOrderId); return api.getUserAnalysis();
} }

View File

@@ -41,43 +41,3 @@ export async function getAccountAdmin({
return data as unknown as AccountWithMemberships; return data as unknown as AccountWithMemberships;
} }
export async function getDoctorAccounts() {
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('accounts')
.select('id, email, name, last_name, preferred_locale')
.eq('is_personal_account', true)
.eq('application_role', 'doctor')
.throwOnError();
return data?.map(({ id, email, name, last_name, preferred_locale }) => ({
id,
email,
name,
lastName: last_name,
preferredLocale: preferred_locale,
}));
}
export async function getAssignedDoctorAccount(analysisOrderId: number) {
const { data: doctorUser } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('doctor_analysis_feedback')
.select('doctor_user_id')
.eq('analysis_order_id', analysisOrderId)
.throwOnError();
const doctorData = doctorUser[0];
if (!doctorData || !doctorData.doctor_user_id) {
return null;
}
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('accounts')
.select('email')
.eq('primary_owner_user_id', doctorData.doctor_user_id);
return { email: data?.[0]?.email };
}

View File

@@ -1,10 +1,8 @@
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
export enum NotificationAction { export enum NotificationAction {
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED', DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
} }
export const createNotificationLog = async ({ export const createNotificationLog = async ({
@@ -19,7 +17,7 @@ export const createNotificationLog = async ({
relatedRecordId?: string | number; relatedRecordId?: string | number;
}) => { }) => {
try { try {
const supabase = getSupabaseServerAdminClient(); const supabase = getSupabaseServerClient();
await supabase await supabase
.schema('audit') .schema('audit')
@@ -32,6 +30,6 @@ export const createNotificationLog = async ({
}) })
.throwOnError(); .throwOnError();
} catch (error) { } catch (error) {
console.error('Failed to insert doctor notification log', error); console.error('Failed to insert doctor page view log', error);
} }
}; };

View File

@@ -1,32 +0,0 @@
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
async function getAssignedOrderIds() {
const supabase = getSupabaseServerAdminClient();
const { data: assignedOrderIds } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('analysis_order_id')
.not('doctor_user_id', 'is', null)
.throwOnError();
return assignedOrderIds?.map((f) => f.analysis_order_id) || [];
}
export async function getOpenJobAnalysisResponseIds() {
const supabase = getSupabaseServerAdminClient();
const assignedIds = await getAssignedOrderIds();
let query = supabase
.schema('medreport')
.from('analysis_responses')
.select('id, analysis_order_id')
.order('created_at', { ascending: false });
if (assignedIds.length > 0) {
query = query.not('analysis_order_id', 'in', `(${assignedIds.join(',')})`);
}
const { data: analysisResponses } = await query.throwOnError();
return analysisResponses?.map(({ id }) => id) || [];
}

View File

@@ -1,41 +1,50 @@
'use server'; 'use server';
import { toArray } from '@/lib/utils'; import { CompanySubmitData } from '@/lib/types/company';
import { emailSchema } from '@/lib/validations/email.schema';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getMailer } from '@kit/mailers'; import { getMailer } from '@kit/mailers';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import { emailSchema } from '~/lib/validations/email.schema'; export const sendDoctorSummaryCompletedEmail = async (
language: string,
type EmailTemplate = { recipientName: string,
html: string; recipientEmail: string,
subject: string; orderNr: string,
}; orderId: number,
type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
export const sendEmailFromTemplate = async <T>(
renderer: EmailRenderer<T>,
templateParams: T,
recipients: string | string[],
) => { ) => {
const { html, subject } = await renderer(templateParams); const { html, subject } = await renderDoctorSummaryReceivedEmail({
language,
recipientName,
recipientEmail,
orderNr,
orderId,
});
const recipientsArray = toArray(recipients); await sendEmail({
if (!recipientsArray.length) {
throw new Error('No valid email recipients provided');
}
const emailPromises = recipientsArray.map((email) =>
sendEmail({
subject, subject,
html, html,
to: email, to: recipientEmail,
}), });
); };
await Promise.all(emailPromises); export const sendCompanyOfferEmail = async (
data: CompanySubmitData,
language: string,
) => {
const { renderCompanyOfferEmail } = await import('@kit/email-templates');
const { html, subject } = await renderCompanyOfferEmail({
language,
companyData: data,
});
await sendEmail({
subject,
html,
to: process.env.CONTACT_EMAIL || '',
});
}; };
export const sendEmail = enhanceAction( export const sendEmail = enhanceAction(
@@ -44,7 +53,7 @@ export const sendEmail = enhanceAction(
const log = await getLogger(); const log = await getLogger();
if (!process.env.EMAIL_USER) { if (!process.env.EMAIL_USER) {
log.error('Sending email failed, as no sender was found in env.'); log.error('Sending email failed, as no sender found in env.')
throw new Error('No email user configured'); throw new Error('No email user configured');
} }

View File

@@ -97,7 +97,7 @@ export async function getLatestPublicMessageListItem() {
Action: MedipostAction.GetPublicMessageList, Action: MedipostAction.GetPublicMessageList,
User: USER, User: USER,
Password: PASSWORD, Password: PASSWORD,
Sender: RECIPIENT, Sender: 'syndev',
// LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created // LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created
// MessageType check only for messages of certain type // MessageType check only for messages of certain type
}, },

View File

@@ -165,20 +165,6 @@ async function doctorMiddleware(request: NextRequest, response: NextResponse) {
*/ */
function getPatterns() { function getPatterns() {
return [ return [
{
pattern: new URLPattern({ pathname: '/' }),
handler: async (req: NextRequest, res: NextResponse) => {
const {
data: { user },
} = await getUser(req, res);
if (user) {
return NextResponse.redirect(
new URL(pathsConfig.app.home, req.nextUrl.origin).href,
);
}
},
},
{ {
pattern: new URLPattern({ pathname: '/admin/*?' }), pattern: new URLPattern({ pathname: '/admin/*?' }),
handler: adminMiddleware, handler: adminMiddleware,

View File

@@ -1,20 +1,7 @@
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import {
renderAllResultsReceivedEmail,
renderFirstResultsReceivedEmail,
} from '@kit/email-templates';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import {
getAssignedDoctorAccount,
getDoctorAccounts,
} from '../../../../../lib/services/account.service';
import {
NotificationAction,
createNotificationLog,
} from '../../../../../lib/services/audit/notificationEntries.service';
import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service';
import { RecordChange, Tables } from '../record-change.type'; import { RecordChange, Tables } from '../record-change.type';
export function createDatabaseWebhookRouterService( export function createDatabaseWebhookRouterService(
@@ -55,12 +42,6 @@ class DatabaseWebhookRouterService {
return this.handleAccountsWebhook(payload); return this.handleAccountsWebhook(payload);
} }
case 'analysis_orders': {
const payload = body as RecordChange<typeof body.table>;
return this.handleAnalysisOrdersWebhook(payload);
}
default: { default: {
return; return;
} }
@@ -102,69 +83,4 @@ class DatabaseWebhookRouterService {
return service.handleAccountDeletedWebhook(body.old_record); return service.handleAccountDeletedWebhook(body.old_record);
} }
} }
private async handleAnalysisOrdersWebhook(
body: RecordChange<'analysis_orders'>,
) {
if (body.type === 'UPDATE' && body.record && body.old_record) {
const { record, old_record } = body;
if (record.status === old_record.status) {
return;
}
let action;
try {
const data = {
analysisOrderId: record.id,
language: 'et',
};
if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') {
action = NotificationAction.NEW_JOBS_ALERT;
const doctorAccounts = await getDoctorAccounts();
const doctorEmails: string[] = doctorAccounts
.map(({ email }) => email)
.filter((email): email is string => !!email);
await sendEmailFromTemplate(
renderFirstResultsReceivedEmail,
data,
doctorEmails,
);
} else if (record.status === 'FULL_ANALYSIS_RESPONSE') {
action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT;
const doctorAccount = await getAssignedDoctorAccount(record.id);
const assignedDoctorEmail = doctorAccount?.email;
if (!assignedDoctorEmail) {
return;
}
await sendEmailFromTemplate(
renderAllResultsReceivedEmail,
data,
assignedDoctorEmail,
);
}
if (action) {
await createNotificationLog({
action,
status: 'SUCCESS',
relatedRecordId: record.id,
});
}
} catch (e: any) {
if (action)
await createNotificationLog({
action,
status: 'FAIL',
comment: e?.message,
relatedRecordId: record.id,
});
}
}
}
} }

View File

@@ -1,82 +0,0 @@
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
render
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
import CommonFooter from '../components/common-footer';
import { EmailContent } from '../components/content';
import { EmailButton } from '../components/email-button';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n';
export async function renderAllResultsReceivedEmail({
language,
analysisOrderId,
}: {
language: string;
analysisOrderId: number;
}) {
const namespace = 'all-results-received-email';
const { t } = await initializeEmailI18n({
language,
namespace: [namespace, 'common'],
});
const previewText = t(`${namespace}:previewText`);
const subject = t(`${namespace}:subject`);
const html = await render(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
{t(`${namespace}:openOrdersHeading`)}
</Text>
<EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
>
{t(`${namespace}:linkText`)}
</EmailButton>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -19,14 +19,16 @@ import { initializeEmailI18n } from '../lib/i18n';
export async function renderDoctorSummaryReceivedEmail({ export async function renderDoctorSummaryReceivedEmail({
language, language,
recipientEmail,
recipientName, recipientName,
orderNr, orderNr,
analysisOrderId, orderId,
}: { }: {
language?: string; language?: string;
recipientName: string; recipientName: string;
recipientEmail: string;
orderNr: string; orderNr: string;
analysisOrderId: number; orderId: number;
}) { }) {
const namespace = 'doctor-summary-received-email'; const namespace = 'doctor-summary-received-email';
@@ -35,6 +37,8 @@ export async function renderDoctorSummaryReceivedEmail({
namespace: [namespace, 'common'], namespace: [namespace, 'common'],
}); });
const to = recipientEmail;
const previewText = t(`${namespace}:previewText`, { const previewText = t(`${namespace}:previewText`, {
orderNr, orderNr,
}); });
@@ -69,13 +73,13 @@ export async function renderDoctorSummaryReceivedEmail({
</Text> </Text>
<EmailButton <EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
> >
{t(`${namespace}:linkText`, { orderNr })} {t(`${namespace}:linkText`, { orderNr })}
</EmailButton> </EmailButton>
<Text> <Text>
{t(`${namespace}:ifButtonDisabled`)}{' '} {t(`${namespace}:ifButtonDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} {`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
</Text> </Text>
<CommonFooter t={t} /> <CommonFooter t={t} />
</EmailContent> </EmailContent>
@@ -88,5 +92,6 @@ export async function renderDoctorSummaryReceivedEmail({
return { return {
html, html,
subject, subject,
to,
}; };
} }

View File

@@ -1,86 +0,0 @@
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
render,
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
import CommonFooter from '../components/common-footer';
import { EmailContent } from '../components/content';
import { EmailButton } from '../components/email-button';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n';
export async function renderFirstResultsReceivedEmail({
language,
analysisOrderId,
}: {
language: string;
analysisOrderId: number;
}) {
const namespace = 'first-results-received-email';
const { t } = await initializeEmailI18n({
language,
namespace: [namespace, 'common'],
});
const previewText = t(`${namespace}:previewText`);
const subject = t(`${namespace}:subject`);
const html = await render(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:resultsReceivedForOrders`)}
</Text>
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
{t(`${namespace}:openOrdersHeading`)}
</Text>
<EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
>
{t(`${namespace}:linkText`)}
</EmailButton>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -1,99 +0,0 @@
import {
Body,
Head,
Html,
Link,
Preview,
Tailwind,
Text,
render
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
import CommonFooter from '../components/common-footer';
import { EmailContent } from '../components/content';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n';
export async function renderNewJobsAvailableEmail({
language,
analysisResponseIds,
}: {
language?: string;
analysisResponseIds: number[];
}) {
const namespace = 'new-jobs-available-email';
const { t } = await initializeEmailI18n({
language,
namespace: [namespace, 'common'],
});
const previewText = t(`${namespace}:previewText`, {
nr: analysisResponseIds.length,
});
const subject = t(`${namespace}:subject`, {
nr: analysisResponseIds.length,
});
const html = await render(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:resultsReceivedForOrders`, {
nr: analysisResponseIds.length,
})}
</Text>
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
{t(`${namespace}:openOrdersHeading`, {
nr: analysisResponseIds.length,
})}
</Text>
<ul className="list-none text-[16px] leading-[24px]">
{analysisResponseIds.map((analysisResponseId, index) => (
<li>
<Link
key={analysisResponseId}
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
>
{t(`${namespace}:linkText`, { nr: index + 1 })}
</Link>
</li>
))}
</ul>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/open-jobs`}
</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -71,7 +71,7 @@ export async function renderOtpEmail(props: Props) {
<Section className="mb-[16px] mt-[16px] text-center"> <Section className="mb-[16px] mt-[16px] text-center">
<Button className={'w-full rounded bg-neutral-950 text-center'}> <Button className={'w-full rounded bg-neutral-950 text-center'}>
<Text className="text-[16px] font-semibold leading-[16px] text-white"> <Text className="text-[16px] font-medium font-semibold leading-[16px] text-white">
{props.otp} {props.otp}
</Text> </Text>
</Button> </Button>

View File

@@ -9,12 +9,12 @@ import {
} from '@react-email/components'; } from '@react-email/components';
import { BodyStyle } from '../components/body-style'; import { BodyStyle } from '../components/body-style';
import CommonFooter from '../components/common-footer';
import { EmailContent } from '../components/content'; import { EmailContent } from '../components/content';
import { EmailHeader } from '../components/header'; import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading'; import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper'; import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n'; import { initializeEmailI18n } from '../lib/i18n';
import CommonFooter from '../components/common-footer';
interface Props { interface Props {
analysisPackageName: string; analysisPackageName: string;
@@ -31,10 +31,7 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
namespace: [namespace, 'common'], namespace: [namespace, 'common'],
}); });
const previewText = t(`${namespace}:previewText`, { const previewText = t(`${namespace}:previewText`);
analysisPackageName: props.analysisPackageName,
});
const subject = t(`${namespace}:subject`, { const subject = t(`${namespace}:subject`, {
analysisPackageName: props.analysisPackageName, analysisPackageName: props.analysisPackageName,
}); });

View File

@@ -4,6 +4,3 @@ export * from './emails/otp.email';
export * from './emails/company-offer.email'; export * from './emails/company-offer.email';
export * from './emails/synlab.email'; export * from './emails/synlab.email';
export * from './emails/doctor-summary-received.email'; export * from './emails/doctor-summary-received.email';
export * from './emails/new-jobs-available.email';
export * from './emails/first-results-received.email';
export * from './emails/all-results-received.email';

View File

@@ -1,8 +0,0 @@
{
"previewText": "All analysis results have been received",
"subject": "All patient analysis results have been received",
"openOrdersHeading": "Review the results and prepare a summary:",
"linkText": "See results",
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
"hello": "Hello"
}

View File

@@ -1,8 +0,0 @@
{
"subject": "New Company Join Request",
"previewText": "The company {{companyName}} is requesting a quote",
"companyName": "Company Name:",
"contactPerson": "Contact Person:",
"email": "Email:",
"phone": "Phone:"
}

View File

@@ -1,9 +0,0 @@
{
"previewText": "First analysis responses received",
"subject": "New job - first analysis responses received",
"resultsReceivedForOrders": "New job available to claim",
"openOrdersHeading": "See here:",
"linkText": "See results",
"ifLinksDisabled": "If the link does not work, you can see available jobs by copying this link into your browser.",
"hello": "Hello,"
}

View File

@@ -1,9 +0,0 @@
{
"previewText": "New jobs available",
"subject": "Please write a summary",
"resultsReceivedForOrders": "Please review the results and write a summary.",
"openOrdersHeading": "See here:",
"linkText": "Open job {{nr}}",
"ifLinksDisabled": "If the links do not work, you can see available jobs by copying this link into your browser.",
"hello": "Hello,"
}

View File

@@ -1,8 +0,0 @@
{
"previewText": "Kõik analüüside vastused on saabunud",
"subject": "Patsiendi kõikide analüüside vastused on saabunud",
"openOrdersHeading": "Vaata tulemusi ja kirjuta kokkuvõte:",
"linkText": "Vaata tulemusi",
"ifLinksDisabled": "Kui link ei tööta, näed analüüsitulemusi sellelt aadressilt:",
"hello": "Tere"
}

View File

@@ -1,9 +0,0 @@
{
"previewText": "Saabusid esimesed analüüside vastused",
"subject": "Uus töö - saabusid esimesed analüüside vastused",
"resultsReceivedForOrders": "Patsiendile saabusid esimesed analüüside vastused.",
"openOrdersHeading": "Vaata siit:",
"linkText": "Vaata tulemusi",
"ifLinksDisabled": "Kui link ei tööta, näed analüüsitulemusi sellelt aadressilt:",
"hello": "Tere"
}

View File

@@ -1,9 +0,0 @@
{
"previewText": "Palun koosta kokkuvõte",
"subject": "Palun koosta kokkuvõte",
"resultsReceivedForOrders": "Palun vaata tulemused üle ja kirjuta kokkuvõte.",
"openOrdersHeading": "Vaata siit:",
"linkText": "Töö {{nr}}",
"ifLinksDisabled": "Kui lingid ei tööta, näed vabasid töid sellelt aadressilt:",
"hello": "Tere"
}

View File

@@ -1,8 +0,0 @@
{
"previewText": "All analysis results have been received",
"subject": "All patient analysis results have been received",
"openOrdersHeading": "Review the results and prepare a summary:",
"linkText": "See results",
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
"hello": "Hello"
}

View File

@@ -1,8 +1,8 @@
{ {
"footer": { "footer": {
"lines1": "MedReport", "lines1": "MedReport",
"lines2": "Электронная почта: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>", "lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
"lines3": "Служба поддержки: <a href=\"tel:+37258871517\">+372 5887 1517</a>", "lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>" "lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
} }
} }

View File

@@ -1,8 +1,8 @@
{ {
"subject": "Новый запрос на присоединение компании", "subject": "Uus ettevõtte liitumispäring",
"previewText": "Компания {{companyName}} запрашивает предложение", "previewText": "Ettevõte {{companyName}} soovib pakkumist",
"companyName": "Название компании:", "companyName": "Ettevõtte nimi:",
"contactPerson": "Контактное лицо:", "contactPerson": "Kontaktisik:",
"email": "Электронная почта:", "email": "E-mail:",
"phone": "Телефон:" "phone": "Telefon:"
} }

View File

@@ -1,8 +1,8 @@
{ {
"subject": "Получено заключение врача по заказу {{orderNr}}", "subject": "Saabus arsti kokkuvõtte tellimusele {{orderNr}}",
"previewText": "Врач отправил заключение по вашим результатам анализа.", "previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.",
"hello": "Здравствуйте, {{displayName}}", "hello": "Tere, {{displayName}}",
"summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.", "summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.",
"linkText": "Посмотреть заключение", "linkText": "Vaata kokkuvõtet",
"ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:" "ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:"
} }

View File

@@ -1,9 +0,0 @@
{
"previewText": "First analysis responses received",
"subject": "New job - first analysis responses received",
"resultsReceivedForOrders": "New job available to claim",
"openOrdersHeading": "See here:",
"linkText": "See results",
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
"hello": "Hello,"
}

View File

@@ -1,9 +0,0 @@
{
"previewText": "New jobs available",
"subject": "Please write a summary",
"resultsReceivedForOrders": "Please review the results and write a summary.",
"openOrdersHeading": "See here:",
"linkText": "Open job {{nr}}",
"ifLinksDisabled": "If the links do not work, you can see available jobs by copying this link into your browser.",
"hello": "Hello,"
}

View File

@@ -1,12 +1,12 @@
{ {
"subject": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}", "subject": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"previewText": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}", "previewText": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"heading": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}", "heading": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"hello": "Здравствуйте, {{personName}},", "hello": "Tere {{personName}},",
"lines1": "Направление на исследование {{analysisPackageName}} было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: Synlab - {{partnerLocationName}}", "lines1": "Saatekiri {{analysisPackageName}} analüüsi uuringuteks on saadetud laborisse digitaalselt. Palun mine proove andma: Synlab - {{partnerLocationName}}",
"lines2": "<i>Если вы не можете посетить выбранный пункт сдачи анализов, вы можете обратиться в удобный для вас пункт - <a href=\"https://medreport.ee/et/verevotupunktid\">посмотреть адреса и часы работы</a>.</i>", "lines2": "<i>Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - <a href=\"https://medreport.ee/et/verevotupunktid\">vaata asukohti ja lahtiolekuaegasid</a>.</i>",
"lines3": "Рекомендуется сдавать анализы утром (до 12:00) натощак (можно пить воду).", "lines3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).",
"lines4": "В пункте сдачи анализов выберите в системе очереди: <strong>направления</strong> -> <strong>направление от специалиста</strong>.", "lines4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>.",
"lines5": "Если у вас возникнут дополнительные вопросы, смело свяжитесь с нами.", "lines5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
"lines6": "Телефон службы поддержки SYNLAB: <a href=\"tel:+37217123\">17123</a>" "lines6": "SYNLAB klienditoe telefon: <a href=\"tel:+37217123\">17123</a>"
} }

View File

@@ -5,16 +5,12 @@ import { useSupabase } from '@kit/supabase/hooks/use-supabase';
type UpdateData = Database['medreport']['Tables']['accounts']['Update']; type UpdateData = Database['medreport']['Tables']['accounts']['Update'];
export function useUpdateAccountData(accountId?: string) { export function useUpdateAccountData(accountId: string) {
const client = useSupabase(); const client = useSupabase();
const mutationKey = ['account:data', accountId]; const mutationKey = ['account:data', accountId];
const mutationFn = async (data: UpdateData) => { const mutationFn = async (data: UpdateData) => {
if (!accountId) {
return null;
}
const response = await client const response = await client
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')

View File

@@ -2,10 +2,7 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { import { UserAnalysis } from '../types/accounts';
AnalysisResultDetails,
UserAnalysis,
} from '../types/accounts';
export type AccountWithParams = export type AccountWithParams =
Database['medreport']['Tables']['accounts']['Row'] & { Database['medreport']['Tables']['accounts']['Row'] & {
@@ -187,49 +184,7 @@ class AccountsApi {
return response.data?.customer_id; return response.data?.customer_id;
} }
async getUserAnalysis( async getUserAnalysis(): Promise<UserAnalysis | null> {
analysisOrderId: number,
): Promise<AnalysisResultDetails | null> {
const authUser = await this.client.auth.getUser();
const { data, error: userError } = authUser;
if (userError) {
console.error('Failed to get user', userError);
throw userError;
}
const { user } = data;
const { data: analysisResponse } = await this.client
.schema('medreport')
.from('analysis_responses')
.select(
`*,
elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time),
order:analysis_order_id(medusa_order_id, status, created_at),
summary:analysis_order_id(doctor_analysis_feedback(*))`,
)
.eq('user_id', user.id)
.eq('analysis_order_id', analysisOrderId)
.throwOnError();
const responseWithElements = analysisResponse?.[0];
if (!responseWithElements) {
return null;
}
const feedback = responseWithElements.summary.doctor_analysis_feedback?.[0];
return {
...responseWithElements,
summary:
feedback?.status === 'COMPLETED'
? responseWithElements.summary.doctor_analysis_feedback?.[0]
: null,
};
}
async getUserAnalyses(): Promise<UserAnalysis | null> {
const authUser = await this.client.auth.getUser(); const authUser = await this.client.auth.getUser();
const { data, error: userError } = authUser; const { data, error: userError } = authUser;

View File

@@ -1,5 +1,3 @@
import * as z from 'zod';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
export type UserAnalysisElement = export type UserAnalysisElement =
@@ -17,51 +15,3 @@ export enum ApplicationRoleEnum {
Doctor = 'doctor', Doctor = 'doctor',
SuperAdmin = 'super_admin', SuperAdmin = 'super_admin',
} }
export const ElementSchema = z.object({
unit: z.string(),
norm_lower: z.number(),
norm_upper: z.number(),
norm_status: z.number(),
analysis_name: z.string(),
response_time: z.string(),
response_value: z.number(),
norm_lower_included: z.boolean(),
norm_upper_included: z.boolean(),
});
export type Element = z.infer<typeof ElementSchema>;
export const OrderSchema = z.object({
status: z.string(),
medusa_order_id: z.string(),
created_at: z.coerce.date(),
});
export type Order = z.infer<typeof OrderSchema>;
export const SummarySchema = z.object({
id: z.number(),
value: z.string(),
status: z.string(),
user_id: z.string(),
created_at: z.coerce.date(),
created_by: z.string(),
updated_at: z.coerce.date().nullable(),
updated_by: z.string(),
doctor_user_id: z.string().nullable(),
analysis_order_id: z.number(),
});
export type Summary = z.infer<typeof SummarySchema>;
export const AnalysisResultDetailsSchema = z.object({
id: z.number(),
analysis_order_id: z.number(),
order_number: z.string(),
order_status: z.string(),
user_id: z.string(),
created_at: z.coerce.date(),
updated_at: z.coerce.date().nullable(),
elements: z.array(ElementSchema),
order: OrderSchema,
summary: SummarySchema.nullable(),
});
export type AnalysisResultDetails = z.infer<typeof AnalysisResultDetailsSchema>;

View File

@@ -7,7 +7,7 @@ export function AuthLayoutShell({
return ( return (
<div <div
className={ className={
'sm:py-auto flex flex-col items-center justify-center py-6' + 'flex h-screen flex-col items-center justify-center' +
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' + ' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' +
' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000' ' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000'
} }

View File

@@ -102,7 +102,6 @@ export const OauthProviders: React.FC<{
redirectTo, redirectTo,
queryParams: props.queryParams, queryParams: props.queryParams,
scopes, scopes,
skipBrowserRedirect: false,
}, },
} satisfies SignInWithOAuthCredentials; } satisfies SignInWithOAuthCredentials;

View File

@@ -41,7 +41,6 @@ export const PatientSchema = z.object({
email: z.string().nullable(), email: z.string().nullable(),
height: z.number().optional().nullable(), height: z.number().optional().nullable(),
weight: z.number().optional().nullable(), weight: z.number().optional().nullable(),
preferred_locale: z.string().nullable(),
}); });
export type Patient = z.infer<typeof PatientSchema>; export type Patient = z.infer<typeof PatientSchema>;

View File

@@ -80,7 +80,6 @@ export const AccountSchema = z.object({
last_name: z.string().nullable(), last_name: z.string().nullable(),
id: z.string(), id: z.string(),
primary_owner_user_id: z.string(), primary_owner_user_id: z.string(),
preferred_locale: z.string().nullable(),
}); });
export type Account = z.infer<typeof AccountSchema>; export type Account = z.infer<typeof AccountSchema>;

View File

@@ -2,11 +2,10 @@ import 'server-only';
import { isBefore } from 'date-fns'; import { isBefore } from 'date-fns';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service'; import { sendDoctorSummaryCompletedEmail } from '../../../../../../../lib/services/mailer.service';
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema'; import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
import { import {
AnalysisResponseBase, AnalysisResponseBase,
@@ -55,7 +54,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
supabase supabase
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
.select('name, last_name, id, primary_owner_user_id, preferred_locale') .select('name, last_name, id, primary_owner_user_id')
.in('primary_owner_user_id', userIds), .in('primary_owner_user_id', userIds),
]); ]);
@@ -68,7 +67,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
? await supabase ? await supabase
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
.select('name, last_name, id, primary_owner_user_id, preferred_locale') .select('name, last_name, id, primary_owner_user_id')
.in('primary_owner_user_id', doctorUserIds) .in('primary_owner_user_id', doctorUserIds)
: { data: [] }; : { data: [] };
@@ -409,7 +408,7 @@ export async function getAnalysisResultsForDoctor(
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
.select( .select(
`primary_owner_user_id, id, name, last_name, personal_code, phone, email, preferred_locale, `primary_owner_user_id, id, name, last_name, personal_code, phone, email,
account_params(height,weight)`, account_params(height,weight)`,
) )
.eq('is_personal_account', true) .eq('is_personal_account', true)
@@ -473,7 +472,6 @@ export async function getAnalysisResultsForDoctor(
personal_code, personal_code,
phone, phone,
account_params, account_params,
preferred_locale,
} = accountWithParams[0]; } = accountWithParams[0];
const analysisResponseElementsWithPreviousData = []; const analysisResponseElementsWithPreviousData = [];
@@ -505,7 +503,6 @@ export async function getAnalysisResultsForDoctor(
}, },
doctorFeedback: doctorFeedback?.[0], doctorFeedback: doctorFeedback?.[0],
patient: { patient: {
preferred_locale,
userId: primary_owner_user_id, userId: primary_owner_user_id,
accountId, accountId,
firstName: name, firstName: name,
@@ -641,7 +638,7 @@ export async function submitFeedback(
} }
if (status === 'COMPLETED') { if (status === 'COMPLETED') {
const [{ data: recipient }, { data: analysisOrder }] = await Promise.all([ const [{ data: recipient }, { data: medusaOrderIds }] = await Promise.all([
supabase supabase
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
@@ -656,33 +653,24 @@ export async function submitFeedback(
.eq('id', analysisOrderId) .eq('id', analysisOrderId)
.limit(1) .limit(1)
.throwOnError(), .throwOnError(),
supabase
.schema('medreport')
.from('analysis_orders')
.update({ status: 'COMPLETED' })
.eq('id', analysisOrderId)
.throwOnError(),
]); ]);
if (!recipient?.[0]?.email) { if (!recipient?.[0]?.email) {
throw new Error('Could not find user email.'); throw new Error('Could not find user email.');
} }
if (!analysisOrder?.[0]?.id) { if (!medusaOrderIds?.[0]?.id) {
throw new Error('Could not retrieve order.'); throw new Error('Could not retrieve order.');
} }
const { preferred_locale, name, last_name, email } = recipient[0]; const { preferred_locale, name, last_name, email } = recipient[0];
await sendEmailFromTemplate( await sendDoctorSummaryCompletedEmail(
renderDoctorSummaryReceivedEmail, preferred_locale ?? 'et',
{ getFullName(name, last_name),
language: preferred_locale ?? 'et',
recipientName: getFullName(name, last_name),
orderNr: analysisOrder?.[0]?.medusa_order_id ?? '',
analysisOrderId: analysisOrder[0].id,
},
email, email,
medusaOrderIds?.[0]?.medusa_order_id ?? '',
medusaOrderIds[0].id,
); );
} }

View File

@@ -14,7 +14,7 @@ export const listProducts = async ({
regionId, regionId,
}: { }: {
pageParam?: number pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[], category_id?: string } queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[] }
countryCode?: string countryCode?: string
regionId?: string regionId?: string
}): Promise<{ }): Promise<{
@@ -63,7 +63,7 @@ export const listProducts = async ({
offset, offset,
region_id: region?.id, region_id: region?.id,
fields: fields:
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,+status", "*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
...queryParams, ...queryParams,
}, },
headers, headers,

View File

@@ -1,8 +1,6 @@
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import SelectAnalysisPackage, { import SelectAnalysisPackage, { AnalysisPackageWithVariant } from './select-analysis-package';
AnalysisPackageWithVariant,
} from './select-analysis-package';
export default function SelectAnalysisPackages({ export default function SelectAnalysisPackages({
analysisPackages, analysisPackages,
@@ -12,16 +10,11 @@ export default function SelectAnalysisPackages({
countryCode: string; countryCode: string;
}) { }) {
return ( return (
<div className="grid gap-6 sm:grid-cols-3"> <div className="grid grid-cols-3 gap-6">
{analysisPackages.length > 0 ? ( {analysisPackages.length > 0 ? analysisPackages.map(
analysisPackages.map((analysisPackage) => ( (analysisPackage) => (
<SelectAnalysisPackage <SelectAnalysisPackage key={analysisPackage.title} analysisPackage={analysisPackage} countryCode={countryCode} />
key={analysisPackage.title} )) : (
analysisPackage={analysisPackage}
countryCode={countryCode}
/>
))
) : (
<h4> <h4>
<Trans i18nKey="order-analysis-package:noPackagesAvailable" /> <Trans i18nKey="order-analysis-package:noPackagesAvailable" />
</h4> </h4>

View File

@@ -13,7 +13,7 @@ export function InfoTooltip({
content, content,
icon, icon,
}: { }: {
content?: JSX.Element | string | null; content?: string | null;
icon?: JSX.Element; icon?: JSX.Element;
}) { }) {
if (!content) return null; if (!content) return null;
@@ -23,7 +23,7 @@ export function InfoTooltip({
<TooltipTrigger> <TooltipTrigger>
{icon || <Info className="size-4 cursor-pointer" />} {icon || <Info className="size-4 cursor-pointer" />}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className='sm:max-w-[400px]'>{content}</TooltipContent> <TooltipContent>{content}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -40,7 +40,7 @@ const authConfig = AuthConfigSchema.parse({
providers: { providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true', password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true', magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
oAuth: ['keycloak'], oAuth: ['google'],
}, },
} satisfies z.infer<typeof AuthConfigSchema>); } satisfies z.infer<typeof AuthConfigSchema>);

View File

@@ -35,6 +35,12 @@ const routes = [
Icon: <ShoppingCart className={iconClasses} />, Icon: <ShoppingCart className={iconClasses} />,
end: true, end: true,
}, },
{
label: 'common:routes.analysisResults',
path: pathsConfig.app.analysisResults,
Icon: <TestTube2 className={iconClasses} />,
end: true,
},
{ {
label: 'common:routes.orderAnalysisPackage', label: 'common:routes.orderAnalysisPackage',
path: pathsConfig.app.orderAnalysisPackage, path: pathsConfig.app.orderAnalysisPackage,

View File

@@ -1,2 +1 @@
export * from './use-csrf-token'; export * from './use-csrf-token';
export * from './use-current-locale-language-names';

View File

@@ -1,17 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
function useLanguageName(currentLanguage: string) {
return useMemo(() => {
return new Intl.DisplayNames([currentLanguage], {
type: 'language',
});
}, [currentLanguage]);
}
export function useCurrentLocaleLanguageNames() {
const { i18n } = useTranslation();
const { language: currentLanguage } = i18n;
return useLanguageName(currentLanguage);
}

View File

@@ -10,11 +10,5 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
export function getSupabaseBrowserClient<GenericSchema = Database>() { export function getSupabaseBrowserClient<GenericSchema = Database>() {
const keys = getSupabaseClientKeys(); const keys = getSupabaseClientKeys();
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey, { return createBrowserClient<GenericSchema>(keys.url, keys.anonKey);
auth: {
flowType: 'pkce',
autoRefreshToken: true,
persistSession: true,
},
});
} }

View File

@@ -20,11 +20,6 @@ export function createMiddlewareClient<GenericSchema = Database>(
const keys = getSupabaseClientKeys(); const keys = getSupabaseClientKeys();
return createServerClient<GenericSchema>(keys.url, keys.anonKey, { return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
auth: {
flowType: 'pkce',
autoRefreshToken: true,
persistSession: true,
},
cookies: { cookies: {
getAll() { getAll() {
return request.cookies.getAll(); return request.cookies.getAll();

View File

@@ -15,11 +15,6 @@ export function getSupabaseServerClient<GenericSchema = Database>() {
const keys = getSupabaseClientKeys(); const keys = getSupabaseClientKeys();
return createServerClient<GenericSchema>(keys.url, keys.anonKey, { return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
auth: {
flowType: 'pkce',
autoRefreshToken: true,
persistSession: true,
},
cookies: { cookies: {
async getAll() { async getAll() {
const cookieStore = await cookies(); const cookieStore = await cookies();

View File

@@ -40,7 +40,7 @@ export function LanguageSelector({
}, [currentLanguage]); }, [currentLanguage]);
const userId = user?.id; const userId = user?.id;
const updateAccountMutation = useUpdateAccountData(userId); const updateAccountMutation = useUpdateAccountData(userId!);
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery(); const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const updateLanguagePreference = async ( const updateLanguagePreference = async (
@@ -52,10 +52,6 @@ export function LanguageSelector({
onChange(locale); onChange(locale);
} }
if (!userId) {
return i18n.changeLanguage(locale);
}
const promise = updateAccountMutation const promise = updateAccountMutation
.mutateAsync({ .mutateAsync({
preferred_locale: locale, preferred_locale: locale,

View File

@@ -1,7 +1,5 @@
import { LanguageSelector } from '@kit/ui/language-selector';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { LanguageSelector } from '@kit/ui/language-selector';
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> { interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
logo?: React.ReactNode; logo?: React.ReactNode;
navigation?: React.ReactNode; navigation?: React.ReactNode;
@@ -24,16 +22,11 @@ export const Header: React.FC<HeaderProps> = function ({
{...props} {...props}
> >
<div className="container"> <div className="container">
<div className="2xs:h-14 2xs:grid-cols-3 grid h-24 w-full items-center"> <div className="grid h-14 grid-cols-3 items-center">
<div className={'mx-auto flex'}>{logo}</div> <div className={'mx-auto md:mx-0'}>{logo}</div>
<div className="2xs:order-none order-first">{navigation}</div> <div className="order-first md:order-none">{navigation}</div>
<div className="2xs:justify-end 2xs:gap-x-2 flex items-center justify-evenly"> <div className="flex items-center justify-end gap-x-2"><div className="max-w-[100px]"><LanguageSelector /></div>{actions}</div>
<div className="max-w-[100px]">
<LanguageSelector />
</div>
{actions}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -42,7 +42,7 @@ function PageWithSidebar(props: PageProps) {
> >
{MobileNavigation} {MobileNavigation}
<div className={'bg-background flex flex-1 flex-col px-4 pb-8 lg:px-0'}> <div className={'bg-background flex flex-1 flex-col px-4 lg:px-0 pb-8'}>
{Children} {Children}
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@ export function PageMobileNavigation(
return ( return (
<div <div
className={cn( className={cn(
'flex w-full items-center px-4 py-2 lg:hidden lg:px-0', 'flex w-full items-center border-b px-4 py-2 lg:hidden lg:px-0',
props.className, props.className,
)} )}
> >

View File

@@ -128,6 +128,5 @@
"updateRoleLoading": "Updating role...", "updateRoleLoading": "Updating role...",
"updatePreferredLocaleSuccess": "Language preference updated", "updatePreferredLocaleSuccess": "Language preference updated",
"updatePreferredLocaleError": "Language preference update failed", "updatePreferredLocaleError": "Language preference update failed",
"updatePreferredLocaleLoading": "Updating language preference...", "updatePreferredLocaleLoading": "Updating language preference..."
"doctorAnalysisSummary": "Doctor's summary"
} }

View File

@@ -12,6 +12,5 @@
"normal": "Normal range" "normal": "Normal range"
} }
}, },
"orderTitle": "Order number {{orderNumber}}", "orderTitle": "Order number {{orderNumber}}"
"view": "View results"
} }

View File

@@ -116,6 +116,5 @@
"confirm": "Confirm", "confirm": "Confirm",
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"invalidDataError": "Invalid data submitted", "invalidDataError": "Invalid data submitted"
"language": "Language"
} }

View File

@@ -17,7 +17,6 @@
"assignedTo": "Doctor", "assignedTo": "Doctor",
"resultsStatus": "Analysis results", "resultsStatus": "Analysis results",
"waitingForNr": "Waiting for {{nr}}", "waitingForNr": "Waiting for {{nr}}",
"language": "Preferred language",
"responsesReceived": "Results complete" "responsesReceived": "Results complete"
}, },
"otherPatients": "Other patients", "otherPatients": "Other patients",

View File

@@ -151,6 +151,5 @@
"updateRoleLoading": "Rolli uuendatakse...", "updateRoleLoading": "Rolli uuendatakse...",
"updatePreferredLocaleSuccess": "Eelistatud keel uuendatud", "updatePreferredLocaleSuccess": "Eelistatud keel uuendatud",
"updatePreferredLocaleError": "Eelistatud keele uuendamine ei õnnestunud", "updatePreferredLocaleError": "Eelistatud keele uuendamine ei õnnestunud",
"updatePreferredLocaleLoading": "Eelistatud keelt uuendatakse...", "updatePreferredLocaleLoading": "Eelistatud keelt uuendatakse..."
"doctorAnalysisSummary": "Arsti kokkuvõte analüüsitulemuste kohta"
} }

View File

@@ -12,6 +12,5 @@
"normal": "Normaalne vahemik" "normal": "Normaalne vahemik"
} }
}, },
"orderTitle": "Tellimus {{orderNumber}}", "orderTitle": "Tellimus {{orderNumber}}"
"view": "Vaata tulemusi"
} }

View File

@@ -136,6 +136,5 @@
"confirm": "Kinnita", "confirm": "Kinnita",
"previous": "Eelmine", "previous": "Eelmine",
"next": "Järgmine", "next": "Järgmine",
"invalidDataError": "Vigased andmed", "invalidDataError": "Vigased andmed"
"language": "Keel"
} }

View File

@@ -17,7 +17,6 @@
"assignedTo": "Arst", "assignedTo": "Arst",
"resultsStatus": "Analüüsitulemused", "resultsStatus": "Analüüsitulemused",
"waitingForNr": "Ootel {{nr}}", "waitingForNr": "Ootel {{nr}}",
"language": "Patsiendi keel",
"responsesReceived": "Tulemused koos" "responsesReceived": "Tulemused koos"
}, },
"otherPatients": "Muud patsiendid", "otherPatients": "Muud patsiendid",

View File

@@ -1,155 +1,132 @@
{ {
"accountTabLabel": "Настройки аккаунта", "accountTabLabel": "Account Settings",
"accountTabDescription": "Управляйте настройками вашего аккаунта", "accountTabDescription": "Manage your account settings",
"homePage": "Главная", "homePage": "Home",
"billingTab": "Оплата", "billingTab": "Billing",
"settingsTab": "Настройки", "settingsTab": "Settings",
"multiFactorAuth": "Многофакторная аутентификация", "multiFactorAuth": "Multi-Factor Authentication",
"multiFactorAuthDescription": "Настройте метод многофакторной аутентификации для дополнительной защиты вашего аккаунта", "multiFactorAuthDescription": "Set up Multi-Factor Authentication method to further secure your account",
"updateProfileSuccess": "Профиль успешно обновлен", "updateProfileSuccess": "Profile successfully updated",
"updateProfileError": "Произошла ошибка. Пожалуйста, попробуйте снова", "updateProfileError": "Encountered an error. Please try again",
"updatePasswordSuccess": "Запрос на обновление пароля выполнен успешно", "updatePasswordSuccess": "Password update request successful",
"updatePasswordSuccessMessage": "Ваш пароль был успешно обновлен!", "updatePasswordSuccessMessage": "Your password has been successfully updated!",
"updatePasswordError": "Произошла ошибка. Пожалуйста, попробуйте снова", "updatePasswordError": "Encountered an error. Please try again",
"updatePasswordLoading": "Обновление пароля...", "updatePasswordLoading": "Updating password...",
"updateProfileLoading": "Обновление профиля...", "updateProfileLoading": "Updating profile...",
"name": "Ваше имя", "name": "Your Name",
"nameDescription": "Обновите ваше имя, которое будет отображаться в профиле", "nameDescription": "Update your name to be displayed on your profile",
"emailLabel": "Адрес электронной почты", "emailLabel": "Email Address",
"accountImage": "Ваша фотография профиля", "accountImage": "Your Profile Picture",
"accountImageDescription": "Выберите фото для загрузки в качестве изображения профиля.", "accountImageDescription": "Please choose a photo to upload as your profile picture.",
"profilePictureHeading": "Загрузить фотографию профиля", "profilePictureHeading": "Upload a Profile Picture",
"profilePictureSubheading": "Выберите фото для загрузки в качестве изображения профиля.", "profilePictureSubheading": "Choose a photo to upload as your profile picture.",
"updateProfileSubmitLabel": "Обновить профиль", "updateProfileSubmitLabel": "Update Profile",
"updatePasswordCardTitle": "Обновите ваш пароль", "updatePasswordCardTitle": "Update your Password",
"updatePasswordCardDescription": "Обновите пароль, чтобы сохранить ваш аккаунт в безопасности.", "updatePasswordCardDescription": "Update your password to keep your account secure.",
"currentPassword": "Текущий пароль", "currentPassword": "Current Password",
"newPassword": "Новый пароль", "newPassword": "New Password",
"repeatPassword": "Повторите новый пароль", "repeatPassword": "Repeat New Password",
"repeatPasswordDescription": "Пожалуйста, повторите новый пароль для подтверждения", "repeatPasswordDescription": "Please repeat your new password to confirm it",
"yourPassword": "Ваш пароль", "yourPassword": "Your Password",
"updatePasswordSubmitLabel": "Обновить пароль", "updatePasswordSubmitLabel": "Update Password",
"updateEmailCardTitle": "Обновите вашу почту", "updateEmailCardTitle": "Update your Email",
"updateEmailCardDescription": "Обновите адрес электронной почты, который вы используете для входа в аккаунт", "updateEmailCardDescription": "Update your email address you use to login to your account",
"newEmail": "Новый адрес электронной почты", "newEmail": "Your New Email",
"repeatEmail": "Повторите адрес электронной почты", "repeatEmail": "Repeat Email",
"updateEmailSubmitLabel": "Обновить адрес электронной почты", "updateEmailSubmitLabel": "Update Email Address",
"updateEmailSuccess": "Запрос на обновление почты выполнен успешно", "updateEmailSuccess": "Email update request successful",
"updateEmailSuccessMessage": "Мы отправили вам письмо для подтверждения нового адреса. Пожалуйста, проверьте почту и перейдите по ссылке для подтверждения.", "updateEmailSuccessMessage": "We sent you an email to confirm your new email address. Please check your inbox and click on the link to confirm your new email address.",
"updateEmailLoading": "Обновление почты...", "updateEmailLoading": "Updating your email...",
"updateEmailError": "Почта не обновлена. Пожалуйста, попробуйте снова", "updateEmailError": "Email not updated. Please try again",
"passwordNotMatching": "Пароли не совпадают. Убедитесь, что вы используете правильный пароль", "passwordNotMatching": "Passwords do not match. Make sure you're using the correct password",
"emailNotMatching": "Адреса электронной почты не совпадают. Убедитесь, что вы используете правильный адрес", "emailNotMatching": "Emails do not match. Make sure you're using the correct email",
"passwordNotChanged": "Ваш пароль не был изменен", "passwordNotChanged": "Your password has not changed",
"emailsNotMatching": "Адреса электронной почты не совпадают. Убедитесь, что вы используете правильный адрес", "emailsNotMatching": "Emails do not match. Make sure you're using the correct email",
"cannotUpdatePassword": "Вы не можете обновить пароль, так как ваш аккаунт не связан ни с одним.", "cannotUpdatePassword": "You cannot update your password because your account is not linked to any.",
"setupMfaButtonLabel": "Настроить новый фактор", "setupMfaButtonLabel": "Setup a new Factor",
"multiFactorSetupErrorHeading": "Сбой настройки", "multiFactorSetupErrorHeading": "Setup Failed",
"multiFactorSetupErrorDescription": "Извините, произошла ошибка при настройке фактора. Пожалуйста, попробуйте снова.", "multiFactorSetupErrorDescription": "Sorry, there was an error while setting up your factor. Please try again.",
"multiFactorAuthHeading": "Защитите ваш аккаунт с помощью многофакторной аутентификации", "multiFactorAuthHeading": "Secure your account with Multi-Factor Authentication",
"multiFactorModalHeading": "Используйте приложение-аутентификатор для сканирования QR-кода ниже, затем введите сгенерированный код.", "multiFactorModalHeading": "Use your authenticator app to scan the QR code below. Then enter the code generated.",
"factorNameLabel": "Запоминающееся имя для идентификации этого фактора", "factorNameLabel": "A memorable name to identify this factor",
"factorNameHint": "Используйте простое для запоминания имя, чтобы легко идентифицировать этот фактор в будущем. Пример: iPhone 14", "factorNameHint": "Use an easy-to-remember name to easily identify this factor in the future. Ex. iPhone 14",
"factorNameSubmitLabel": "Задать имя фактора", "factorNameSubmitLabel": "Set factor name",
"unenrollTooltip": "Удалить фактор", "unenrollTooltip": "Unenroll this factor",
"unenrollingFactor": "Удаление фактора...", "unenrollingFactor": "Unenrolling factor...",
"unenrollFactorSuccess": "Фактор успешно удален", "unenrollFactorSuccess": "Factor successfully unenrolled",
"unenrollFactorError": "Не удалось удалить фактор", "unenrollFactorError": "Unenrolling factor failed",
"factorsListError": "Ошибка загрузки списка факторов", "factorsListError": "Error loading factors list",
"factorsListErrorDescription": "Извините, не удалось загрузить список факторов. Пожалуйста, попробуйте снова.", "factorsListErrorDescription": "Sorry, we couldn't load the factors list. Please try again.",
"factorName": "Имя фактора", "factorName": "Factor Name",
"factorType": "Тип", "factorType": "Type",
"factorStatus": "Статус", "factorStatus": "Status",
"mfaEnabledSuccessTitle": "Многофакторная аутентификация включена", "mfaEnabledSuccessTitle": "Multi-Factor authentication is enabled",
"mfaEnabledSuccessDescription": "Поздравляем! Вы успешно подключили многофакторную аутентификацию. Теперь вы сможете входить в аккаунт с помощью комбинации пароля и кода подтверждения, отправленного на ваш номер телефона.", "mfaEnabledSuccessDescription": "Congratulations! You have successfully enrolled in the multi factor authentication process. You will now be able to access your account with a combination of your password and an authentication code sent to your phone number.",
"verificationCode": "Код подтверждения", "verificationCode": "Verification Code",
"addEmailAddress": "Добавить адрес электронной почты", "addEmailAddress": "Add Email address",
"verifyActivationCodeDescription": "Введите 6-значный код, сгенерированный вашим приложением-аутентификатором, в поле выше", "verifyActivationCodeDescription": "Enter the 6-digit code generated by your authenticator app in the field above",
"loadingFactors": "Загрузка факторов...", "loadingFactors": "Loading factors...",
"enableMfaFactor": "Включить фактор", "enableMfaFactor": "Enable Factor",
"disableMfaFactor": "Отключить фактор", "disableMfaFactor": "Disable Factor",
"qrCodeErrorHeading": "Ошибка QR-кода", "qrCodeErrorHeading": "QR Code Error",
"qrCodeErrorDescription": "Извините, не удалось сгенерировать QR-код", "qrCodeErrorDescription": "Sorry, we weren't able to generate the QR code",
"multiFactorSetupSuccess": "Фактор успешно подключен", "multiFactorSetupSuccess": "Factor successfully enrolled",
"submitVerificationCode": "Отправить код подтверждения", "submitVerificationCode": "Submit Verification Code",
"mfaEnabledSuccessAlert": "Многофакторная аутентификация включена", "mfaEnabledSuccessAlert": "Multi-Factor authentication is enabled",
"verifyingCode": "Проверка кода...", "verifyingCode": "Verifying code...",
"invalidVerificationCodeHeading": "Неверный код подтверждения", "invalidVerificationCodeHeading": "Invalid Verification Code",
"invalidVerificationCodeDescription": "Введенный вами код неверен. Пожалуйста, попробуйте снова.", "invalidVerificationCodeDescription": "The verification code you entered is invalid. Please try again.",
"unenrollFactorModalHeading": "Удаление фактора", "unenrollFactorModalHeading": "Unenroll Factor",
"unenrollFactorModalDescription": "Вы собираетесь удалить этот фактор. Вы больше не сможете использовать его для входа в аккаунт.", "unenrollFactorModalDescription": "You're about to unenroll this factor. You will not be able to use it to login to your account.",
"unenrollFactorModalBody": "Вы собираетесь удалить этот фактор. Вы больше не сможете использовать его для входа в аккаунт.", "unenrollFactorModalBody": "You're about to unenroll this factor. You will not be able to use it to login to your account.",
"unenrollFactorModalButtonLabel": "Да, удалить фактор", "unenrollFactorModalButtonLabel": "Yes, unenroll factor",
"selectFactor": "Выберите фактор для подтверждения личности", "selectFactor": "Choose a factor to verify your identity",
"disableMfa": "Отключить многофакторную аутентификацию", "disableMfa": "Disable Multi-Factor Authentication",
"disableMfaButtonLabel": "Отключить MFA", "disableMfaButtonLabel": "Disable MFA",
"confirmDisableMfaButtonLabel": "Да, отключить MFA", "confirmDisableMfaButtonLabel": "Yes, disable MFA",
"disablingMfa": "Отключение многофакторной аутентификации. Пожалуйста, подождите...", "disablingMfa": "Disabling Multi-Factor Authentication. Please wait...",
"disableMfaSuccess": "Многофакторная аутентификация успешно отключена", "disableMfaSuccess": "Multi-Factor Authentication successfully disabled",
"disableMfaError": "Извините, произошла ошибка. MFA не была отключена.", "disableMfaError": "Sorry, we encountered an error. MFA has not been disabled.",
"sendingEmailVerificationLink": "Отправка письма...", "sendingEmailVerificationLink": "Sending Email...",
"sendEmailVerificationLinkSuccess": "Ссылка для подтверждения успешно отправлена", "sendEmailVerificationLinkSuccess": "Verification link successfully sent",
"sendEmailVerificationLinkError": "Извините, не удалось отправить письмо", "sendEmailVerificationLinkError": "Sorry, we weren't able to send you the email",
"sendVerificationLinkSubmitLabel": "Отправить ссылку для подтверждения", "sendVerificationLinkSubmitLabel": "Send Verification Link",
"sendVerificationLinkSuccessLabel": "Письмо отправлено! Проверьте почту", "sendVerificationLinkSuccessLabel": "Email sent! Check your Inbox",
"verifyEmailAlertHeading": "Пожалуйста, подтвердите вашу почту, чтобы включить MFA", "verifyEmailAlertHeading": "Please verify your email to enable MFA",
"verificationLinkAlertDescription": "Ваша почта еще не подтверждена. Пожалуйста, подтвердите ее, чтобы настроить многофакторную аутентификацию.", "verificationLinkAlertDescription": "Your email is not yet verified. Please verify your email to be able to set up Multi-Factor Authentication.",
"authFactorName": "Имя фактора (необязательно)", "authFactorName": "Factor Name (optional)",
"authFactorNameHint": "Присвойте имя, которое поможет вам запомнить номер телефона, используемый для входа", "authFactorNameHint": "Assign a name that helps you remember the phone number used",
"loadingUser": "Загрузка данных пользователя. Пожалуйста, подождите...", "loadingUser": "Loading user details. Please wait...",
"linkPhoneNumber": "Привязать номер телефона", "linkPhoneNumber": "Link Phone Number",
"dangerZone": "Опасная зона", "dangerZone": "Danger Zone",
"dangerZoneDescription": "Некоторые действия нельзя отменить. Будьте осторожны.", "dangerZoneDescription": "Some actions cannot be undone. Please be careful.",
"deleteAccount": "Удалить аккаунт", "deleteAccount": "Delete your Account",
"deletingAccount": "Удаление аккаунта. Пожалуйста, подождите...", "deletingAccount": "Deleting account. Please wait...",
"deleteAccountDescription": "Это удалит ваш аккаунт и связанные с ним учетные записи. Также мы немедленно отменим все активные подписки. Это действие нельзя отменить.", "deleteAccountDescription": "This will delete your account and the accounts you own. Furthermore, we will immediately cancel any active subscriptions. This action cannot be undone.",
"deleteProfileConfirmationInputLabel": "Введите DELETE для подтверждения", "deleteProfileConfirmationInputLabel": "Type DELETE to confirm",
"deleteAccountErrorHeading": "Извините, не удалось удалить аккаунт", "deleteAccountErrorHeading": "Sorry, we couldn't delete your account",
"needsReauthentication": "Требуется повторная аутентификация", "needsReauthentication": "Reauthentication Required",
"needsReauthenticationDescription": "Необходимо повторно войти в систему, чтобы изменить пароль. Пожалуйста, выйдите и войдите снова.", "needsReauthenticationDescription": "You need to reauthenticate to change your password. Please sign out and sign in again to change your password.",
"language": "Язык", "language": "Language",
"languageDescription": "Выберите предпочитаемый язык", "languageDescription": "Choose your preferred language",
"noTeamsYet": "У вас пока нет команд.", "noTeamsYet": "You don't have any teams yet.",
"createTeam": "Создайте команду, чтобы начать.", "createTeam": "Create a team to get started.",
"createTeamButtonLabel": "Создать команду", "createTeamButtonLabel": "Create a Team",
"createCompanyAccount": "Создать аккаунт компании", "createCompanyAccount": "Create Company Account",
"requestCompanyAccount": { "updateConsentSuccess": "Consent successfully updated",
"title": "Данные компании", "updateConsentError": "Encountered an error. Please try again",
"description": "Чтобы получить предложение, пожалуйста, введите данные компании, с которой вы планируете использовать MedReport.", "updateConsentLoading": "Updating consent...",
"button": "Запросить предложение",
"successTitle": "Запрос успешно отправлен!",
"successDescription": "Мы ответим вам при первой возможности",
"successButton": "Вернуться на главную"
},
"updateAccount": {
"title": "Личные данные",
"description": "Пожалуйста, введите свои личные данные для продолжения",
"button": "Продолжить",
"userConsentLabel": "Я согласен на использование моих персональных данных на платформе",
"userConsentUrlTitle": "Посмотреть политику обработки персональных данных"
},
"consentModal": {
"title": "Прежде чем начать",
"description": "Вы даете согласие на использование ваших медицинских данных в анонимной форме для статистики работодателя? Данные будут обезличены и помогут компании лучше поддерживать здоровье сотрудников.",
"reject": "Не даю согласие",
"accept": "Да, даю согласие"
},
"updateConsentSuccess": "Согласия обновлены",
"updateConsentError": "Что-то пошло не так. Пожалуйста, попробуйте снова",
"updateConsentLoading": "Обновление согласий...",
"consentToAnonymizedCompanyData": { "consentToAnonymizedCompanyData": {
"label": "Согласен участвовать в статистике работодателя", "label": "Consent to be included in employer statistics",
"description": "Я согласен на использование моих медицинских данных в анонимной форме для статистики работодателя" "description": "Consent to be included in anonymized company statistics"
}, },
"membershipConfirmation": { "analysisResults": {
"successTitle": "Здравствуйте, {{firstName}} {{lastName}}", "pageTitle": "My analysis results"
"successDescription": "Ваш медицинский аккаунт активирован и готов к использованию!",
"successButton": "Продолжить"
}, },
"updateRoleSuccess": "Роль обновлена", "updateRoleSuccess": "Role updated",
"updateRoleError": "Что-то пошло не так. Пожалуйста, попробуйте снова", "updateRoleError": "Something went wrong, please try again",
"updateRoleLoading": "Обновление роли...", "updateRoleLoading": "Updating role...",
"updatePreferredLocaleSuccess": "Предпочитаемый язык обновлен", "updatePreferredLocaleSuccess": "Language preference updated",
"updatePreferredLocaleError": "Не удалось обновить предпочитаемый язык", "updatePreferredLocaleError": "Language preference update failed",
"updatePreferredLocaleLoading": "Обновление предпочитаемого языка..." "updatePreferredLocaleLoading": "Updating language preference..."
} }

View File

@@ -1,16 +0,0 @@
{
"pageTitle": "Мои результаты анализов",
"description": "Все результаты анализов появляются в течение 1-3 рабочих дней после их сдачи.",
"descriptionEmpty": "Если вы уже сдали анализы, то вскоре здесь появятся ваши результаты.",
"orderNewAnalysis": "Заказать новые анализы",
"waitingForResults": "Ожидание результатов",
"noAnalysisElements": "Анализы еще не заказаны",
"noAnalysisOrders": "Пока нет заказов на анализы",
"analysisDate": "Дата результата анализа",
"results": {
"range": {
"normal": "Нормальный диапазон"
}
},
"orderTitle": "Заказ {{orderNumber}}"
}

View File

@@ -1,90 +1,90 @@
{ {
"signUpHeading": "Создать аккаунт", "signUpHeading": "Create an account",
"signUp": "Зарегистрироваться", "signUp": "Sign Up",
"signUpSubheading": "Заполните форму ниже, чтобы создать аккаунт.", "signUpSubheading": "Fill the form below to create an account.",
"signInHeading": "Войдите в свой аккаунт", "signInHeading": "Sign in to your account",
"signInSubheading": "С возвращением! Пожалуйста, введите свои данные", "signInSubheading": "Welcome back! Please enter your details",
"signIn": "Войти", "signIn": "Sign In",
"getStarted": "Начать", "getStarted": "Get started",
"updatePassword": "Обновить пароль", "updatePassword": "Update Password",
"signOut": "Выйти", "signOut": "Sign out",
"signingIn": "Вход...", "signingIn": "Signing in...",
"signingUp": "Регистрация...", "signingUp": "Signing up...",
"doNotHaveAccountYet": "Еще нет аккаунта?", "doNotHaveAccountYet": "Do not have an account yet?",
"alreadyHaveAnAccount": "Уже есть аккаунт?", "alreadyHaveAnAccount": "Already have an account?",
"signUpToAcceptInvite": "Пожалуйста, войдите или зарегистрируйтесь, чтобы принять приглашение", "signUpToAcceptInvite": "Please sign in/up to accept the invite",
"clickToAcceptAs": "Нажмите кнопку ниже, чтобы принять приглашение как <b>{{email}}</b>", "clickToAcceptAs": "Click the button below to accept the invite with as <b>{{email}}</b>",
"acceptInvite": "Принять приглашение", "acceptInvite": "Accept invite",
"acceptingInvite": "Принятие приглашения...", "acceptingInvite": "Accepting Invite...",
"acceptInviteSuccess": "Приглашение успешно принято", "acceptInviteSuccess": "Invite successfully accepted",
"acceptInviteError": "Произошла ошибка при принятии приглашения", "acceptInviteError": "Error encountered while accepting invite",
"acceptInviteWithDifferentAccount": "Хотите принять приглашение с другим аккаунтом?", "acceptInviteWithDifferentAccount": "Want to accept the invite with a different account?",
"alreadyHaveAccountStatement": "У меня уже есть аккаунт, я хочу войти", "alreadyHaveAccountStatement": "I already have an account, I want to sign in instead",
"doNotHaveAccountStatement": "У меня нет аккаунта, я хочу зарегистрироваться", "doNotHaveAccountStatement": "I do not have an account, I want to sign up instead",
"signInWithProvider": "Войти через {{provider}}", "signInWithProvider": "Sign in with {{provider}}",
"signInWithPhoneNumber": "Войти по номеру телефона", "signInWithPhoneNumber": "Sign in with Phone Number",
"signInWithEmail": "Войти по Email", "signInWithEmail": "Sign in with Email",
"signUpWithEmail": "Зарегистрироваться по Email", "signUpWithEmail": "Sign up with Email",
"passwordHint": "Убедитесь, что пароль содержит не менее 8 символов", "passwordHint": "Ensure it's at least 8 characters",
"repeatPasswordHint": "Введите пароль еще раз", "repeatPasswordHint": "Type your password again",
"repeatPassword": "Повторите пароль", "repeatPassword": "Repeat password",
"passwordForgottenQuestion": "Забыли пароль?", "passwordForgottenQuestion": "Password forgotten?",
"passwordResetLabel": "Сбросить пароль", "passwordResetLabel": "Reset Password",
"passwordResetSubheading": "Введите ваш email ниже. Вы получите ссылку для сброса пароля.", "passwordResetSubheading": "Enter your email address below. You will receive a link to reset your password.",
"passwordResetSuccessMessage": "Проверьте почту! Мы отправили вам ссылку для сброса пароля.", "passwordResetSuccessMessage": "Check your Inbox! We emailed you a link for resetting your Password.",
"passwordRecoveredQuestion": "Восстановили пароль?", "passwordRecoveredQuestion": "Password recovered?",
"passwordLengthError": "Пожалуйста, введите пароль длиной не менее 6 символов", "passwordLengthError": "Please provide a password with at least 6 characters",
"sendEmailLink": "Отправить ссылку на почту", "sendEmailLink": "Send Email Link",
"sendingEmailLink": "Отправка ссылки...", "sendingEmailLink": "Sending Email Link...",
"sendLinkSuccessDescription": "Проверьте вашу почту, мы только что отправили вам ссылку. Перейдите по ней, чтобы войти.", "sendLinkSuccessDescription": "Check your email, we just sent you a link. Follow the link to sign in.",
"sendLinkSuccess": "Мы отправили вам ссылку по электронной почте", "sendLinkSuccess": "We sent you a link by email",
"sendLinkSuccessToast": "Ссылка успешно отправлена", "sendLinkSuccessToast": "Link successfully sent",
"getNewLink": "Получить новую ссылку", "getNewLink": "Get a new link",
"verifyCodeHeading": "Подтвердите ваш аккаунт", "verifyCodeHeading": "Verify your account",
"verificationCode": "Код подтверждения", "verificationCode": "Verification Code",
"verificationCodeHint": "Введите код, который мы отправили вам по SMS", "verificationCodeHint": "Enter the code we sent you by SMS",
"verificationCodeSubmitButtonLabel": "Отправить код", "verificationCodeSubmitButtonLabel": "Submit Verification Code",
"sendingMfaCode": "Отправка кода подтверждения...", "sendingMfaCode": "Sending Verification Code...",
"verifyingMfaCode": "Проверка кода...", "verifyingMfaCode": "Verifying code...",
"sendMfaCodeError": "Извините, не удалось отправить код подтверждения", "sendMfaCodeError": "Sorry, we couldn't send you a verification code",
"verifyMfaCodeSuccess": "Код подтвержден! Входим в систему...", "verifyMfaCodeSuccess": "Code verified! Signing you in...",
"verifyMfaCodeError": "Упс! Похоже, код неверный", "verifyMfaCodeError": "Ops! It looks like the code is not correct",
"reauthenticate": "Повторная аутентификация", "reauthenticate": "Reauthenticate",
"reauthenticateDescription": "В целях безопасности вам нужно пройти повторную аутентификацию", "reauthenticateDescription": "For security reasons, we need you to re-authenticate",
"errorAlertHeading": "Извините, не удалось вас аутентифицировать", "errorAlertHeading": "Sorry, we could not authenticate you",
"emailConfirmationAlertHeading": "Мы отправили вам письмо для подтверждения.", "emailConfirmationAlertHeading": "We sent you a confirmation email.",
"emailConfirmationAlertBody": "Добро пожаловать! Пожалуйста, проверьте вашу почту и перейдите по ссылке, чтобы подтвердить аккаунт.", "emailConfirmationAlertBody": "Welcome! Please check your email and click the link to verify your account.",
"resendLink": "Отправить ссылку снова", "resendLink": "Resend link",
"resendLinkSuccessDescription": "Мы отправили новую ссылку на вашу почту! Перейдите по ней, чтобы войти.", "resendLinkSuccessDescription": "We sent you a new link to your email! Follow the link to sign in.",
"resendLinkSuccess": "Проверьте вашу почту!", "resendLinkSuccess": "Check your email!",
"authenticationErrorAlertHeading": "Ошибка аутентификации", "authenticationErrorAlertHeading": "Authentication Error",
"authenticationErrorAlertBody": "Извините, не удалось вас аутентифицировать. Пожалуйста, попробуйте снова.", "authenticationErrorAlertBody": "Sorry, we could not authenticate you. Please try again.",
"sendEmailCode": "Получить код на Email", "sendEmailCode": "Get code to your Email",
"sendingEmailCode": "Отправка кода...", "sendingEmailCode": "Sending code...",
"resetPasswordError": "Извините, не удалось сбросить пароль. Пожалуйста, попробуйте снова.", "resetPasswordError": "Sorry, we could not reset your password. Please try again.",
"emailPlaceholder": "your@email.com", "emailPlaceholder": "your@email.com",
"inviteAlertHeading": "Вас пригласили присоединиться к компании", "inviteAlertHeading": "You have been invited to join a company",
"inviteAlertBody": "Пожалуйста, войдите или зарегистрируйтесь, чтобы принять приглашение и присоединиться к компании.", "inviteAlertBody": "Please sign in or sign up to accept the invite and join the company.",
"acceptTermsAndConditions": "Я принимаю <TermsOfServiceLink /> и <PrivacyPolicyLink />", "acceptTermsAndConditions": "I accept the <TermsOfServiceLink /> and <PrivacyPolicyLink />",
"termsOfService": "Условия использования", "termsOfService": "Terms of Service",
"privacyPolicy": "Политика конфиденциальности", "privacyPolicy": "Privacy Policy",
"orContinueWith": "Или продолжить через", "orContinueWith": "Or continue with",
"redirecting": "Вы вошли! Пожалуйста, подождите...", "redirecting": "You're in! Please wait...",
"errors": { "errors": {
"Invalid login credentials": "Введенные данные недействительны", "Invalid login credentials": "The credentials entered are invalid",
"User already registered": "Эти данные уже используются. Пожалуйста, попробуйте другие.", "User already registered": "This credential is already in use. Please try with another one.",
"Email not confirmed": "Пожалуйста, подтвердите ваш email перед входом", "Email not confirmed": "Please confirm your email address before signing in",
"default": "Произошла ошибка. Убедитесь, что у вас есть подключение к интернету, и попробуйте снова", "default": "We have encountered an error. Please ensure you have a working internet connection and try again",
"generic": "Извините, не удалось вас аутентифицировать. Пожалуйста, попробуйте снова.", "generic": "Sorry, we weren't able to authenticate you. Please try again.",
"link": "Извините, произошла ошибка при отправке ссылки. Пожалуйста, попробуйте снова.", "link": "Sorry, we encountered an error while sending your link. Please try again.",
"codeVerifierMismatch": "Похоже, вы пытаетесь войти через другой браузер, чем тот, в котором запрашивали ссылку для входа. Пожалуйста, используйте тот же браузер.", "codeVerifierMismatch": "It looks like you're trying to sign in using a different browser than the one you used to request the sign in link. Please try again using the same browser.",
"minPasswordLength": "Пароль должен содержать не менее 8 символов", "minPasswordLength": "Password must be at least 8 characters long",
"passwordsDoNotMatch": "Пароли не совпадают", "passwordsDoNotMatch": "The passwords do not match",
"minPasswordNumbers": "Пароль должен содержать хотя бы одну цифру", "minPasswordNumbers": "Password must contain at least one number",
"minPasswordSpecialChars": "Пароль должен содержать хотя бы один специальный символ", "minPasswordSpecialChars": "Password must contain at least one special character",
"uppercasePassword": "Пароль должен содержать хотя бы одну заглавную букву", "uppercasePassword": "Password must contain at least one uppercase letter",
"insufficient_aal": "Пожалуйста, войдите с текущей многофакторной аутентификацией, чтобы выполнить это действие", "insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action",
"otp_expired": "Срок действия ссылки на email истек. Пожалуйста, попробуйте снова.", "otp_expired": "The email link has expired. Please try again.",
"same_password": "Пароль не может совпадать с текущим паролем" "same_password": "The password cannot be the same as the current password"
} }
} }

View File

@@ -1,143 +1,123 @@
{ {
"subscriptionTabSubheading": "Управление подпиской и оплатой", "subscriptionTabSubheading": "Manage your Subscription and Billing",
"planCardTitle": "Ваш тариф", "planCardTitle": "Your Plan",
"planCardDescription": "Ниже приведены детали вашего текущего тарифа. Вы можете изменить тариф или отменить подписку в любое время.", "planCardDescription": "Below are the details of your current plan. You can change your plan or cancel your subscription at any time.",
"planRenewal": "Продлевается каждые {{interval}} за {{price}}", "planRenewal": "Renews every {{interval}} at {{price}}",
"planDetails": "Детали тарифа", "planDetails": "Plan Details",
"checkout": "Перейти к оплате", "checkout": "Proceed to Checkout",
"trialEndsOn": "Пробный период заканчивается", "trialEndsOn": "Your trial ends on",
"billingPortalCardButton": "Перейти в биллинг-портал", "billingPortalCardButton": "Visit Billing Portal",
"billingPortalCardTitle": "Управление платежными данными", "billingPortalCardTitle": "Manage your Billing Details",
"billingPortalCardDescription": "Перейдите в биллинг-портал, чтобы управлять подпиской и платежами. Вы можете изменить или отменить тариф, а также скачать счета.", "billingPortalCardDescription": "Visit your Billing Portal to manage your subscription and billing. You can update or cancel your plan, or download your invoices.",
"cancelAtPeriodEndDescription": "Ваша подписка будет отменена {{- endDate }}.", "cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.",
"renewAtPeriodEndDescription": "Ваша подписка будет продлена {{- endDate }}", "renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}",
"noPermissionsAlertHeading": "У вас нет прав для изменения настроек оплаты", "noPermissionsAlertHeading": "You don't have permissions to change the billing settings",
"noPermissionsAlertBody": "Пожалуйста, свяжитесь с администратором аккаунта, чтобы изменить настройки оплаты.", "noPermissionsAlertBody": "Please contact your account admin to change the billing settings for your account.",
"checkoutSuccessTitle": "Готово! Всё настроено.", "checkoutSuccessTitle": "Done! You're all set.",
"checkoutSuccessDescription": "Спасибо за подписку! Мы успешно оформили вашу подписку. Подтверждение отправлено на {{ customerEmail }}.", "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {{ customerEmail }}.",
"checkoutSuccessBackButton": "Перейти в приложение", "checkoutSuccessBackButton": "Proceed to App",
"cannotManageBillingAlertTitle": "Вы не можете управлять оплатой", "cannotManageBillingAlertTitle": "You cannot manage billing",
"cannotManageBillingAlertDescription": "У вас нет прав для управления оплатой. Пожалуйста, свяжитесь с администратором аккаунта.", "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account admin.",
"manageTeamPlan": "Управление корпоративным тарифом", "manageTeamPlan": "Manage your Company Plan",
"manageTeamPlanDescription": "Выберите тариф, который соответствует потребностям вашей компании. Вы можете изменить его в любое время.", "manageTeamPlanDescription": "Choose a plan that fits your company's needs. You can upgrade or downgrade your plan at any time.",
"basePlan": "Базовый тариф", "basePlan": "Base Plan",
"billingInterval": { "billingInterval": {
"label": "Выберите интервал оплаты", "label": "Choose your billing interval",
"month": "Оплата ежемесячно", "month": "Billed monthly",
"year": "Оплата ежегодно" "year": "Billed yearly"
}, },
"perMonth": "месяц", "perMonth": "month",
"custom": "Индивидуальный тариф", "custom": "Custom Plan",
"lifetime": "Пожизненный", "lifetime": "Lifetime",
"trialPeriod": "Пробный период {{period}} дней", "trialPeriod": "{{period}} day trial",
"perPeriod": "за {{period}}", "perPeriod": "per {{period}}",
"redirectingToPayment": "Перенаправление на оплату. Пожалуйста, подождите...", "redirectingToPayment": "Redirecting to checkout. Please wait...",
"proceedToPayment": "Перейти к оплате", "proceedToPayment": "Proceed to Payment",
"startTrial": "Начать пробный период", "startTrial": "Start Trial",
"perTeamMember": "На одного сотрудника", "perTeamMember": "Per company member",
"perUnit": "За использование {{unit}}", "perUnit": "Per {{unit}} usage",
"teamMembers": "Сотрудники компании", "teamMembers": "Company Members",
"includedUpTo": "До {{upTo}} {{unit}} включено в тариф", "includedUpTo": "Up to {{upTo}} {{unit}} included in the plan",
"fromPreviousTierUpTo": "за каждый {{unit}} для следующих {{ upTo }} {{ unit }}", "fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}",
"andAbove": "свыше {{ previousTier }} {{ unit }}", "andAbove": "above {{ previousTier }} {{ unit }}",
"startingAtPriceUnit": "Начиная с {{price}}/{{unit}}", "startingAtPriceUnit": "Starting at {{price}}/{{unit}}",
"priceUnit": "{{price}}/{{unit}}", "priceUnit": "{{price}}/{{unit}}",
"forEveryUnit": "за каждый {{ unit }}", "forEveryUnit": "for every {{ unit }}",
"setupFee": "плюс плата за установку {{ setupFee }}", "setupFee": "plus a {{ setupFee }} setup fee",
"perUnitIncluded": "({{included}} включено)", "perUnitIncluded": "({{included}} included)",
"featuresLabel": "Функции", "featuresLabel": "Features",
"detailsLabel": "Подробности", "detailsLabel": "Details",
"planPickerLabel": "Выберите тариф", "planPickerLabel": "Pick your preferred plan",
"planCardLabel": "Управление тарифом", "planCardLabel": "Manage your Plan",
"planPickerAlertErrorTitle": "Ошибка при запросе оплаты", "planPickerAlertErrorTitle": "Error requesting checkout",
"planPickerAlertErrorDescription": "Произошла ошибка при запросе оплаты. Пожалуйста, попробуйте позже.", "planPickerAlertErrorDescription": "There was an error requesting checkout. Please try again later.",
"subscriptionCancelled": "Подписка отменена", "subscriptionCancelled": "Subscription Cancelled",
"cancelSubscriptionDate": "Ваша подписка будет отменена в конце расчетного периода", "cancelSubscriptionDate": "Your subscription will be cancelled at the end of the period",
"noPlanChosen": "Пожалуйста, выберите тариф", "noPlanChosen": "Please choose a plan",
"noIntervalPlanChosen": "Пожалуйста, выберите интервал оплаты", "noIntervalPlanChosen": "Please choose a billing interval",
"status": { "status": {
"free": { "free": {
"badge": "Бесплатный тариф", "badge": "Free Plan",
"heading": "Вы используете бесплатный тариф", "heading": "You are currently on the Free Plan",
"description": "Вы на бесплатном тарифе. Вы можете перейти на платный тариф в любое время." "description": "You're on a free plan. You can upgrade to a paid plan at any time."
}, },
"active": { "active": {
"badge": "Активен", "badge": "Active",
"heading": "Ваша подписка активна", "heading": "Your subscription is active",
"description": "Ваша подписка активна. Вы можете управлять подпиской и оплатой в клиентском портале." "description": "Your subscription is active. You can manage your subscription and billing in the Customer Portal."
}, },
"trialing": { "trialing": {
"badge": "Пробный период", "badge": "Trial",
"heading": "Вы используете пробный период", "heading": "You're on a trial",
"description": "Вы можете пользоваться тарифом до окончания пробного периода" "description": "You can enjoy the benefits of plan until the trial ends"
}, },
"past_due": { "past_due": {
"badge": "Просрочено", "badge": "Past Due",
"heading": "Ваш счет просрочен", "heading": "Your invoice is past due",
"description": "Ваш счет просрочен. Пожалуйста, обновите способ оплаты." "description": "Your invoice is past due. Please update your payment method."
}, },
"canceled": { "canceled": {
"badge": "Отменено", "badge": "Canceled",
"heading": "Ваша подписка отменена", "heading": "Your subscription is canceled",
"description": "Ваша подписка отменена и завершится в конце расчетного периода." "description": "Your subscription is canceled. It is scheduled to end at end of the billing period."
}, },
"unpaid": { "unpaid": {
"badge": "Неоплачено", "badge": "Unpaid",
"heading": "Ваш счет не оплачен", "heading": "Your invoice is unpaid",
"description": "Ваш счет не оплачен. Пожалуйста, обновите способ оплаты." "description": "Your invoice is unpaid. Please update your payment method."
}, },
"incomplete": { "incomplete": {
"badge": "Не завершено", "badge": "Incomplete",
"heading": "Мы ждем вашу оплату", "heading": "We're waiting for your payment",
"description": "Мы ждем завершения вашей оплаты. Пожалуйста, подождите." "description": "We're waiting for your payment to go through. Please bear with us."
}, },
"incomplete_expired": { "incomplete_expired": {
"badge": "Истек", "badge": "Expired",
"heading": "Срок оплаты истек", "heading": "Your payment has expired",
"description": "Срок оплаты истек. Пожалуйста, обновите способ оплаты." "description": "Your payment has expired. Please update your payment method."
}, },
"paused": { "paused": {
"badge": "Приостановлено", "badge": "Paused",
"heading": "Ваша подписка приостановлена", "heading": "Your subscription is paused",
"description": "Ваша подписка приостановлена. Вы можете возобновить ее в любое время." "description": "Your subscription is paused. You can resume it at any time."
}, },
"succeeded": { "succeeded": {
"badge": "Успешно", "badge": "Succeeded",
"heading": "Оплата прошла успешно", "heading": "Your payment was successful",
"description": "Оплата прошла успешно. Спасибо за подписку!" "description": "Your payment was successful. Thank you for subscribing!"
}, },
"pending": { "pending": {
"badge": "В ожидании", "badge": "Pending",
"heading": "Оплата в ожидании", "heading": "Your payment is pending",
"description": "Оплата находится в ожидании. Пожалуйста, подождите." "description": "Your payment is pending. Please bear with us."
}, },
"failed": { "failed": {
"badge": "Неудачно", "badge": "Failed",
"heading": "Оплата не прошла", "heading": "Your payment failed",
"description": "Оплата не прошла. Пожалуйста, обновите способ оплаты." "description": "Your payment failed. Please update your payment method."
} }
}, },
"cart": { "cart": {
"label": "Корзина ({{ items }})" "label": "Cart ({{ items }})"
},
"pageTitle": "Бюджет {{companyName}}",
"description": "Выберите подходящую дату в календаре и запишитесь на прием.",
"saveChanges": "Сохранить изменения",
"healthBenefitForm": {
"title": "Форма здоровья",
"description": "Поддержка сотрудника из корпоративного фонда здоровья",
"info": "* К ценам добавляются государственные налоги"
},
"occurance": {
"yearly": "Раз в год",
"quarterly": "Раз в квартал",
"monthly": "Раз в месяц"
},
"expensesOverview": {
"title": "Обзор расходов за 2025 год",
"monthly": "Расход на одного сотрудника в месяц *",
"yearly": "Максимальный расход на одного человека в год *",
"total": "Максимальный расход на {{employeeCount}} сотрудников в год *",
"sum": "Итого"
} }
} }

View File

@@ -1,9 +1,8 @@
{ {
"title": "Выберите услугу", "title": "Select service",
"description": "Выберите подходящую услугу или пакет в зависимости от вашей проблемы со здоровьем или цели.", "description": "Select the appropriate service or package according to your health needs or goals.",
"analysisPackages": { "analysisPackages": {
"title": "Пакеты анализов", "title": "Analysis packages",
"description": "Ознакомьтесь с персональными пакетами анализов и закажите" "description": "Get to know the personal analysis packages and order"
}, }
"noCategories": "Список услуг не найден, попробуйте позже"
} }

View File

@@ -1,75 +0,0 @@
{
"title": "Корзина",
"description": "Просмотрите свою корзину",
"emptyCartMessage": "Ваша корзина пуста",
"emptyCartMessageDescription": "Добавьте товары в корзину, чтобы продолжить.",
"subtotal": "Промежуточный итог",
"total": "Сумма",
"table": {
"item": "Товар",
"quantity": "Количество",
"price": "Цена",
"total": "Сумма"
},
"checkout": {
"goToCheckout": "Оформить заказ",
"goToDashboard": "Продолжить",
"error": {
"title": "Что-то пошло не так",
"description": "Пожалуйста, попробуйте позже."
},
"timeLeft": "Осталось времени {{timeLeft}}",
"timeoutTitle": "Бронирование истекло",
"timeoutDescription": "Бронирование товара {{productTitle}} в корзине истекло.",
"timeoutAction": "Продолжить"
},
"discountCode": {
"title": "Подарочная карта или промокод",
"label": "Добавить промокод",
"apply": "Применить",
"subtitle": "Если хотите, можете добавить промокод",
"placeholder": "Введите промокод"
},
"items": {
"synlabAnalyses": {
"productColumnLabel": "Название анализа"
},
"ttoServices": {
"productColumnLabel": "Название услуги"
},
"delete": {
"success": "Товар удален из корзины",
"loading": "Удаление товара из корзины",
"error": "Не удалось удалить товар из корзины"
},
"analysisLocation": {
"success": "Местоположение обновлено",
"loading": "Обновление местоположения",
"error": "Не удалось обновить местоположение"
}
},
"order": {
"title": "Заказ"
},
"orderConfirmed": {
"title": "Заказ успешно оформлен",
"summary": "Услуги",
"subtotal": "Промежуточный итог",
"taxes": "Налоги",
"giftCard": "Подарочная карта",
"total": "Сумма",
"orderDate": "Дата заказа",
"orderNumber": "Номер заказа",
"orderStatus": "Статус заказа",
"paymentStatus": "Статус оплаты"
},
"montonioCallback": {
"title": "Процесс оплаты Montonio",
"description": "Пожалуйста, подождите, пока мы завершим обработку вашего платежа."
},
"locations": {
"title": "Местоположение для сдачи анализов",
"description": "Если у вас нет возможности прийти в выбранное место для сдачи анализов, вы можете посетить любой удобный для вас пункт забора крови.",
"locationSelect": "Выберите местоположение"
}
}

View File

@@ -1,140 +1,118 @@
{ {
"homeTabLabel": "Главная", "homeTabLabel": "Home",
"homeTabDescription": "Добро пожаловать на вашу домашнюю страницу", "homeTabDescription": "Welcome to your home page",
"accountMembers": "Члены компании", "accountMembers": "Company Members",
"membersTabDescription": "Здесь вы можете управлять членами вашей компании.", "membersTabDescription": "Here you can manage the members of your company.",
"billingTabLabel": "Оплата", "billingTabLabel": "Billing",
"billingTabDescription": "Управление оплатой и подпиской", "billingTabDescription": "Manage your billing and subscription",
"dashboardTabLabel": "Панель", "dashboardTabLabel": "Dashboard",
"settingsTabLabel": "Настройки", "settingsTabLabel": "Settings",
"profileSettingsTabLabel": "Профиль", "profileSettingsTabLabel": "Profile",
"subscriptionSettingsTabLabel": "Подписка", "subscriptionSettingsTabLabel": "Subscription",
"dashboardTabDescription": "Обзор активности и эффективности вашей учетной записи по всем проектам.", "dashboardTabDescription": "An overview of your account's activity and performance across all your projects.",
"settingsTabDescription": "Управляйте своими настройками и предпочтениями.", "settingsTabDescription": "Manage your settings and preferences.",
"emailAddress": "Электронная почта", "emailAddress": "Email Address",
"password": "Пароль", "password": "Password",
"modalConfirmationQuestion": "Вы уверены, что хотите продолжить?", "modalConfirmationQuestion": "Are you sure you want to continue?",
"imageInputLabel": "Нажмите здесь, чтобы загрузить изображение", "imageInputLabel": "Click here to upload an image",
"cancel": "Отмена", "cancel": "Cancel",
"clear": "Очистить", "clear": "Clear",
"close": "Закрыть", "close": "Close",
"notFound": "Не найдено", "notFound": "Not Found",
"backToHomePage": "Вернуться на главную", "backToHomePage": "Back to Home Page",
"goBack": "Назад", "goBack": "Go Back",
"genericServerError": "Извините, что-то пошло не так.", "genericServerError": "Sorry, something went wrong.",
"genericServerErrorHeading": "Извините, произошла ошибка при обработке вашего запроса. Пожалуйста, свяжитесь с нами, если проблема не исчезнет.", "genericServerErrorHeading": "Sorry, something went wrong while processing your request. Please contact us if the issue persists.",
"pageNotFound": "Извините, эта страница не существует.", "pageNotFound": "Sorry, this page does not exist.",
"pageNotFoundSubHeading": "К сожалению, запрашиваемая страница не найдена", "pageNotFoundSubHeading": "Apologies, the page you were looking for was not found",
"genericError": "Извините, что-то пошло не так.", "genericError": "Sorry, something went wrong.",
"genericErrorSubHeading": "К сожалению, произошла ошибка при обработке вашего запроса. Пожалуйста, свяжитесь с нами, если проблема не исчезнет.", "genericErrorSubHeading": "Apologies, an error occurred while processing your request. Please contact us if the issue persists.",
"anonymousUser": "Анонимный пользователь", "anonymousUser": "Anonymous",
"tryAgain": "Попробовать снова", "tryAgain": "Try Again",
"theme": "Тема", "theme": "Theme",
"lightTheme": "Светлая", "lightTheme": "Light",
"darkTheme": "Тёмная", "darkTheme": "Dark",
"systemTheme": "Системная", "systemTheme": "System",
"expandSidebar": "Развернуть боковое меню", "expandSidebar": "Expand Sidebar",
"collapseSidebar": "Свернуть боковое меню", "collapseSidebar": "Collapse Sidebar",
"documentation": "Документация", "documentation": "Documentation",
"getStarted": "Начать!", "getStarted": "Get Started",
"getStartedWithPlan": "Начать с {{plan}}", "getStartedWithPlan": "Get Started with {{plan}}",
"retry": "Повторить", "retry": "Retry",
"contactUs": "Свяжитесь с нами", "contactUs": "Contact Us",
"loading": "Загрузка. Пожалуйста, подождите...", "loading": "Loading. Please wait...",
"yourAccounts": "Ваши аккаунты", "yourAccounts": "Your Accounts",
"continue": "Продолжить", "continue": "Continue",
"skip": "Пропустить", "skip": "Skip",
"signedInAs": "Вы вошли как", "signedInAs": "Signed in as",
"pageOfPages": "Страница {{page}} / {{total}}", "pageOfPages": "Page {{page}} of {{total}}",
"noData": "Нет данных", "noData": "No data available",
"pageNotFoundHeading": "Упс! :|", "pageNotFoundHeading": "Ouch! :|",
"errorPageHeading": "Упс! :|", "errorPageHeading": "Ouch! :|",
"notifications": "Уведомления", "notifications": "Notifications",
"noNotifications": "Нет уведомлений", "noNotifications": "No notifications",
"justNow": "Прямо сейчас", "justNow": "Just now",
"newVersionAvailable": "Доступна новая версия", "newVersionAvailable": "New version available",
"newVersionAvailableDescription": "Доступна новая версия приложения. Рекомендуется обновить страницу, чтобы получить последние обновления и избежать возможных проблем.", "newVersionAvailableDescription": "A new version of the app is available. It is recommended to refresh the page to get the latest updates and avoid any issues.",
"newVersionSubmitButton": "Перезагрузить и обновить", "newVersionSubmitButton": "Reload and Update",
"back": "Назад", "back": "Back",
"welcome": "Добро пожаловать", "welcome": "Welcome",
"shoppingCart": "Корзина", "shoppingCart": "Shopping cart",
"shoppingCartCount": "Корзина ({{count}})", "search": "Search{{end}}",
"search": "Поиск{{end}}", "myActions": "My actions",
"myActions": "Мои действия",
"healthPackageComparison": { "healthPackageComparison": {
"label": "Сравнение пакетов здоровья", "label": "Health package comparison",
"description": "Ниже приведен персональный выбор пакета медицинского обследования на основе предварительной информации (пол, возраст и индекс массы тела). В таблице можно добавить к рекомендуемому пакету отдельные исследования." "description": "Alljärgnevalt on antud eelinfo (sugu, vanus ja kehamassiindeksi) põhjal tehtud personalne terviseauditi valik. Tabelis on võimalik soovitatud terviseuuringute paketile lisada üksikuid uuringuid juurde."
}, },
"routes": { "routes": {
"home": "Главная", "home": "Home",
"overview": "Обзор", "overview": "Overview",
"booking": "Забронировать время", "booking": "Booking",
"myOrders": "Мои заказы", "myOrders": "My orders",
"analysisResults": "Результаты анализов", "orderAnalysis": "Order analysis",
"orderAnalysisPackage": "Заказать пакет анализов", "orderAnalysisPackage": "Order analysis package",
"orderAnalysis": "Заказать анализ", "orderHealthAnalysis": "Order health analysis",
"orderHealthAnalysis": "Заказать обследование", "account": "Account",
"account": "Аккаунт", "members": "Members",
"members": "Участники", "billing": "Billing",
"billing": "Оплата", "dashboard": "Dashboard",
"dashboard": "Обзор", "settings": "Settings",
"settings": "Настройки", "profile": "Profile",
"profile": "Профиль", "application": "Application"
"application": "Приложение",
"pickTime": "Выберите время"
}, },
"roles": { "roles": {
"owner": { "owner": {
"label": "Администратор" "label": "Admin"
}, },
"member": { "member": {
"label": "Участник" "label": "Member"
} }
}, },
"otp": { "otp": {
"requestVerificationCode": "Запросить код подтверждения", "requestVerificationCode": "Request Verification Code",
"requestVerificationCodeDescription": "Мы должны подтвердить вашу личность для продолжения. Мы отправим код подтверждения на электронный адрес {{email}}.", "requestVerificationCodeDescription": "We must verify your identity to continue with this action. We'll send a verification code to the email address {{email}}.",
"sendingCode": "Отправка кода...", "sendingCode": "Sending Code...",
"sendVerificationCode": "Отправить код подтверждения", "sendVerificationCode": "Send Verification Code",
"enterVerificationCode": "Введите код подтверждения", "enterVerificationCode": "Enter Verification Code",
"codeSentToEmail": "Мы отправили код подтверждения на электронный адрес {{email}}.", "codeSentToEmail": "We've sent a verification code to the email address {{email}}.",
"verificationCode": "Код подтверждения", "verificationCode": "Verification Code",
"enterCodeFromEmail": "Введите 6-значный код, который мы отправили на вашу почту.", "enterCodeFromEmail": "Enter the 6-digit code we sent to your email.",
"verifying": "Проверка...", "verifying": "Verifying...",
"verifyCode": "Подтвердить код", "verifyCode": "Verify Code",
"requestNewCode": "Запросить новый код", "requestNewCode": "Request New Code",
"errorSendingCode": "Ошибка при отправке кода. Пожалуйста, попробуйте снова." "errorSendingCode": "Error sending code. Please try again."
}, },
"cookieBanner": { "cookieBanner": {
"title": "Привет, мы используем куки 🍪", "title": "Hey, we use cookies 🍪",
"description": "Этот сайт использует файлы cookie, чтобы обеспечить вам наилучший опыт.", "description": "This website uses cookies to ensure you get the best experience on our website.",
"reject": "Отклонить", "reject": "Reject",
"accept": "Принять" "accept": "Accept"
}, },
"formField": { "doctor": "Doctor",
"companyName": "Название компании", "save": "Save",
"contactPerson": "Контактное лицо", "saveAsDraft": "Save as draft",
"email": "Электронная почта", "confirm": "Confirm",
"phone": "Телефон", "previous": "Previous",
"firstName": "Имя", "next": "Next",
"lastName": "Фамилия", "invalidDataError": "Invalid data submitted"
"personalCode": "Личный код",
"city": "Город",
"weight": "Вес",
"height": "Рост",
"occurance": "Частота поддержки",
"amount": "Сумма",
"selectDate": "Выберите дату"
},
"wallet": {
"balance": "Баланс вашего MedReport аккаунта",
"expiredAt": "Действительно до {{expiredAt}}"
},
"doctor": "Врач",
"save": "Сохранить",
"saveAsDraft": "Сохранить как черновик",
"confirm": "Подтвердить",
"previous": "Предыдущий",
"next": "Следующий",
"invalidDataError": "Некорректные данные"
} }

View File

@@ -1,22 +1,16 @@
{ {
"recentlyCheckedDescription": "Отлично, вы проверили своё здоровье. Вот важные показатели для вас", "recentlyCheckedDescription": "Super, oled käinud tervist kontrollimas. Siin on sinule olulised näitajad",
"respondToQuestion": "Ответить на вопрос", "respondToQuestion": "Respond",
"gender": "Пол", "gender": "Gender",
"male": "Мужчина", "male": "Male",
"female": "Женщина", "female": "Female",
"age": "Возраст", "age": "Age",
"height": "Рост", "height": "Height",
"weight": "Вес", "weight": "Weight",
"bmi": "ИМТ", "bmi": "BMI",
"bloodPressure": "Давление", "bloodPressure": "Blood pressure",
"cholesterol": "Холестерин", "cholesterol": "Cholesterol",
"ldlCholesterol": "ЛПНП холестерин", "ldlCholesterol": "LDL Cholesterol",
"smoking": "Курение", "smoking": "Smoking",
"recommendedForYou": "Рекомендации для вас", "recommendedForYou": "Recommended for you"
"heroCard": {
"orderAnalysis": {
"title": "Заказать анализ",
"description": "Закажите подходящий для вас анализ"
}
}
} }

View File

@@ -1,51 +1,50 @@
{ {
"sidebar": { "sidebar": {
"dashboard": "Обзор", "dashboard": "Overview",
"openReviews": "Свободные задания", "openReviews": "Open jobs",
"myReviews": "Мои задания", "myReviews": "My jobs",
"completedReviews": "Выполненные задания" "completedReviews": "Completed jobs"
}, },
"openReviews": "Свободные задания", "openReviews": "Open jobs",
"myReviews": "Мои задания", "myReviews": "My jobs",
"completedReviews": "Выполненные задания", "completedReviews": "Completed jobs",
"otherReviews": "Другие задания", "otherReviews": "Other jobs",
"resultsTable": { "resultsTable": {
"patientName": "Имя пациента", "patientName": "Patient name",
"serviceName": "Услуга", "serviceName": "Service",
"orderNr": "Номер заказа", "orderNr": "Order number",
"time": "Время", "time": "Time",
"assignedTo": "Врач", "assignedTo": "Doctor",
"resultsStatus": "Результаты анализов", "resultsStatus": "Analysis results",
"waitingForNr": "В ожидании {{nr}}", "waitingForNr": "Waiting for {{nr}}",
"language": "Предпочтительный язык", "responsesReceived": "Results complete"
"responsesReceived": "Результаты готовы"
}, },
"otherPatients": "Другие пациенты", "otherPatients": "Other patients",
"analyses": "Анализы", "analyses": "Analyses",
"open": "Открыть", "open": "Open",
"name": "Имя", "name": "Name",
"personalCode": "Личный код", "personalCode": "Personal code",
"dobAndAge": "Дата рождения и возраст", "dobAndAge": "Date of birth and age",
"bmi": "Индекс массы тела", "bmi": "Body mass index",
"smoking": "Курение", "smoking": "Smoking",
"phone": "Телефон", "phone": "Phone",
"email": "Эл. почта", "email": "Email",
"results": "Результаты анализов", "results": "Analysis results",
"feedback": "Заключение", "feedback": "Summary",
"selectJob": "Выбрать", "selectJob": "Select",
"unselectJob": "Отказаться", "unselectJob": "Unselect",
"previousResults": "Предыдущие результаты ({{date}})", "previousResults": "Previous results ({{date}})",
"labComment": "Комментарии лаборатории", "labComment": "Lab comment",
"confirmFeedbackModal": { "confirmFeedbackModal": {
"title": "Подтвердите публикацию заключения", "title": "Confirm publishing summary",
"description": "После подтверждения заключение будет опубликовано для пациента." "description": "When confirmed, the summary will be published to the patient."
}, },
"error": { "error": {
"UNKNOWN": "Что-то пошло не так", "UNKNOWN": "Something went wrong",
"JOB_ASSIGNED": "Задание уже занято" "JOB_ASSIGNED": "Job already selected"
}, },
"updateFeedbackSuccess": "Заключение обновлено", "updateFeedbackSuccess": "Summary updated",
"updateFeedbackLoading": "Обновление заключения...", "updateFeedbackLoading": "Updating summary...",
"updateFeedbackError": "Не удалось обновить заключение", "updateFeedbackError": "Failed to update summary",
"feedbackLengthError": "Заключение должно содержать не менее 10 символов" "feedbackLengthError": "Summary must be at least 10 characters"
} }

View File

@@ -1,41 +1,41 @@
{ {
"blog": "Блог", "blog": "Blog",
"blogSubtitle": "Новости и обновления о платформе", "blogSubtitle": "News and updates about the platform",
"documentation": "Документация", "documentation": "Documentation",
"documentationSubtitle": "Учебники и руководство по началу работы с платформой", "documentationSubtitle": "Tutorials and guide to get started with the platform",
"faq": "FAQ", "faq": "FAQ",
"faqSubtitle": "Часто задаваемые вопросы о платформе", "faqSubtitle": "Frequently asked questions about the platform",
"pricing": "Цены", "pricing": "Pricing",
"pricingSubtitle": "Тарифные планы и варианты оплаты", "pricingSubtitle": "Pricing plans and payment options",
"backToBlog": "Назад к блогу", "backToBlog": "Back to blog",
"noPosts": "Посты не найдены", "noPosts": "No posts found",
"blogPaginationNext": "Следующая страница", "blogPaginationNext": "Next Page",
"blogPaginationPrevious": "Предыдущая страница", "blogPaginationPrevious": "Previous Page",
"readMore": "Читать далее", "readMore": "Read more",
"contactFaq": "Если у вас есть вопросы, пожалуйста, свяжитесь с нами", "contactFaq": "If you have any questions, please contact us",
"contact": "Контакты", "contact": "Contact",
"about": "О нас", "about": "About",
"product": "Продукт", "product": "Product",
"legal": "Правовая информация", "legal": "Legal",
"termsOfService": "Условия обслуживания", "termsOfService": "Terms of Service",
"termsOfServiceDescription": "Наши правила и условия", "termsOfServiceDescription": "Our terms and conditions",
"cookiePolicy": "Политика использования файлов cookie", "cookiePolicy": "Cookie Policy",
"cookiePolicyDescription": "Наша политика использования cookie и как мы их применяем", "cookiePolicyDescription": "Our cookie policy and how we use them",
"privacyPolicy": "Политика конфиденциальности", "privacyPolicy": "Privacy Policy",
"privacyPolicyDescription": "Наша политика конфиденциальности и как мы используем ваши данные", "privacyPolicyDescription": "Our privacy policy and how we use your data",
"contactDescription": "Свяжитесь с нами по любым вопросам или отзывам", "contactDescription": "Contact us for any questions or feedback",
"contactHeading": "Отправьте нам сообщение", "contactHeading": "Send us a message",
"contactSubheading": "Мы свяжемся с вами как можно скорее", "contactSubheading": "We will get back to you as soon as possible",
"contactName": "Ваше имя", "contactName": "Your Name",
"contactEmail": "Ваш email", "contactEmail": "Your Email",
"contactMessage": "Ваше сообщение", "contactMessage": "Your Message",
"sendMessage": "Отправить сообщение", "sendMessage": "Send Message",
"contactSuccess": "Ваше сообщение успешно отправлено", "contactSuccess": "Your message has been sent successfully",
"contactError": "Произошла ошибка при отправке сообщения", "contactError": "An error occurred while sending your message",
"contactSuccessDescription": "Мы получили ваше сообщение и свяжемся с вами в ближайшее время", "contactSuccessDescription": "We have received your message and will get back to you as soon as possible",
"contactErrorDescription": "Произошла ошибка при отправке сообщения. Пожалуйста, попробуйте позже", "contactErrorDescription": "An error occurred while sending your message. Please try again later",
"footerDescription": "Здесь вы можете добавить описание вашей компании или продукта", "footerDescription": "Here you can add a description about your company or product",
"copyright": "© Copyright {{year}} {{product}}. Все права защищены.", "copyright": "© Copyright {{year}} {{product}}. All Rights Reserved.",
"heroSubtitle": "Простой, удобный и быстрый обзор вашего здоровья", "heroSubtitle": "A simple, convenient, and quick overview of your health condition",
"notInterestedInAudit": "Не хочу проходить аудит здоровья в данный момент" "notInterestedInAudit": "Currently not interested in a health audit"
} }

View File

@@ -1,9 +1,7 @@
{ {
"title": "Выберите пакет анализов", "title": "Select analysis package",
"noPackagesAvailable": "Список услуг не найден, попробуйте позже", "noPackagesAvailable": "No packages available",
"selectThisPackage": "Выбрать этот пакет", "selectThisPackage": "Select this package",
"selectPackage": "Выберите пакет", "selectPackage": "Select package",
"comparePackages": "Сравнить пакеты", "comparePackages": "Compare packages"
"analysisPackageAddedToCart": "Пакет анализов добавлен в корзину",
"analysisPackageAddToCartError": "Не удалось добавить пакет анализов в корзину"
} }

View File

@@ -1,7 +0,0 @@
{
"title": "Выберите анализ",
"description": "Результаты всех анализов будут доступны в течение 13 рабочих дней после сдачи крови.",
"analysisNotAvailable": "Заказ анализа в данный момент недоступен",
"analysisAddedToCart": "Анализ добавлен в корзину",
"analysisAddToCartError": "Не удалось добавить анализ в корзину"
}

View File

@@ -1,19 +0,0 @@
{
"title": "Заказы",
"description": "Просмотрите ваши заказы",
"table": {
"analysisPackage": "Пакет анализов",
"otherOrders": "Заказ",
"createdAt": "Дата заказа",
"status": "Статус"
},
"status": {
"QUEUED": "Отправлено",
"PROCESSING": "Передано в Synlab",
"PARTIAL_ANALYSIS_RESPONSE": "Частичные результаты",
"FULL_ANALYSIS_RESPONSE": "Все результаты получены, ожидается заключение врача",
"COMPLETED": "Подтверждено",
"REJECTED": "Возвращено",
"CANCELLED": "Отменено"
}
}

View File

@@ -1,31 +1,31 @@
{ {
"nrOfAnalyses": "{{nr}} анализов", "nrOfAnalyses": "{{nr}} analyses",
"clinicalBloodDraw": { "clinicalBloodDraw": {
"label": "Клинический анализ крови", "label": "Kliiniline vereanalüüs",
"description": "Ожидается" "description": "Pending"
}, },
"crp": { "crp": {
"label": "С-реактивный белок (CRP)", "label": "C-reaktiivne valk (CRP)",
"description": "Ожидается" "description": "Pending"
}, },
"ferritin": { "ferritin": {
"label": "Ферритин", "label": "Ferritiin",
"description": "Ожидается" "description": "Pending"
}, },
"vitaminD": { "vitaminD": {
"label": "Витамин D", "label": "D-vitamiin",
"description": "Ожидается" "description": "Pending"
}, },
"glucose": { "glucose": {
"label": "Глюкоза", "label": "Glükoos",
"description": "Ожидается" "description": "Pending"
}, },
"alat": { "alat": {
"label": "Аланинаминотрансфераза", "label": "Alaniini aminotransferaas",
"description": "Ожидается" "description": "Pending"
}, },
"ast": { "ast": {
"label": "Аспартатаминотрансфераза", "label": "Aspartaadi aminotransferaas",
"description": "Ожидается" "description": "Pending"
} }
} }

View File

@@ -1,197 +1,164 @@
{ {
"home": { "home": {
"pageTitle": "Обзор", "pageTitle": "Home"
"headerTitle": "Обзор Tervisekassa {{companyName}}",
"healthDetails": "Данные о здоровье компании",
"membersSettingsButtonTitle": "Управление сотрудниками",
"membersSettingsButtonDescription": "Добавляйте, редактируйте или удаляйте сотрудников.",
"membersBillingButtonTitle": "Управление бюджетом",
"membersBillingButtonDescription": "Выберите, как распределять бюджет между сотрудниками."
}, },
"settings": { "settings": {
"pageTitle": "Настройки", "pageTitle": "Settings",
"pageDescription": "Управление данными вашей компании", "pageDescription": "Manage your Company details",
"teamLogo": "Логотип компании", "teamLogo": "Company Logo",
"teamLogoDescription": "Обновите логотип вашей компании для упрощения идентификации", "teamLogoDescription": "Update your company's logo to make it easier to identify",
"teamName": "Название компании", "teamName": "Company Name",
"teamNameDescription": "Обновите название вашей компании", "teamNameDescription": "Update your company's name",
"dangerZone": "Опасная зона", "dangerZone": "Danger Zone",
"dangerZoneDescription": "Этот раздел содержит действия, которые невозможно отменить" "dangerZoneDescription": "This section contains actions that are irreversible"
}, },
"members": { "members": {
"pageTitle": "Сотрудники" "pageTitle": "Members"
}, },
"billing": { "billing": {
"pageTitle": "Биллинг" "pageTitle": "Billing"
}, },
"benefitStatistics": { "yourTeams": "Your Companies ({{teamsCount}})",
"budget": { "createTeam": "Create a Company",
"title": "Баланс Tervisekassa компании", "creatingTeam": "Creating Company...",
"balance": "Остаток бюджета {{balance}}", "personalAccount": "Personal Account",
"volume": "Объем бюджета {{volume}}" "searchAccount": "Search Account...",
}, "membersTabLabel": "Members",
"data": { "memberName": "Name",
"reservations": "{{value}} услуги", "youLabel": "You",
"analysis": "Анализы", "emailLabel": "Email",
"doctorsAndSpecialists": "Врачи и специалисты", "roleLabel": "Role",
"researches": "Исследования", "primaryOwnerLabel": "Primary Admin",
"healthResearchPlans": "Пакеты медицинских исследований", "joinedAtLabel": "Joined at",
"serviceUsage": "{{value}} использование услуг", "invitedAtLabel": "Invited at",
"serviceSum": "Сумма услуг", "inviteMembersPageSubheading": "Invite members to your Company",
"eclinic": "Дигиклиника" "createTeamModalHeading": "Create Company",
} "createTeamModalDescription": "Create a new Company to manage your projects and members.",
}, "teamNameLabel": "Company Name",
"healthDetails": { "teamNameDescription": "Your company name should be unique and descriptive",
"women": "Женщины", "createTeamSubmitLabel": "Create Company",
"men": "Мужчины", "createTeamSuccess": "Company created successfully",
"avgAge": "Средний возраст", "createTeamError": "Company not created. Please try again.",
"bmi": "ИМТ", "createTeamLoading": "Creating company...",
"cholesterol": "Общий холестерин", "settingsPageLabel": "General",
"vitaminD": "Витамин D", "createTeamDropdownLabel": "New company",
"smokers": "Курящие" "changeRole": "Change Role",
}, "removeMember": "Remove from Account",
"yourTeams": "Ваши компании ({{teamsCount}})", "inviteMembersSuccess": "Members invited successfully!",
"createTeam": "Создать компанию", "inviteMembersError": "Sorry, we encountered an error! Please try again",
"creatingTeam": "Создание компании...", "inviteMembersLoading": "Inviting members...",
"personalAccount": "Личный аккаунт", "removeInviteButtonLabel": "Remove invite",
"searchAccount": "Поиск аккаунта...", "addAnotherMemberButtonLabel": "Add another one",
"membersTabLabel": "Сотрудники", "inviteMembersButtonLabel": "Send Invites",
"memberName": "Имя", "removeMemberModalHeading": "You are removing this user",
"youLabel": "Вы", "removeMemberModalDescription": "Remove this member from the company. They will no longer have access to the company.",
"emailLabel": "E-mail", "removeMemberSuccessMessage": "Member removed successfully",
"roleLabel": "Роль", "removeMemberErrorMessage": "Sorry, we encountered an error. Please try again",
"primaryOwnerLabel": "Главный админ", "removeMemberErrorHeading": "Sorry, we couldn't remove the selected member.",
"joinedAtLabel": "Присоединился", "removeMemberLoadingMessage": "Removing member...",
"invitedAtLabel": "Приглашен", "removeMemberSubmitLabel": "Remove User from Company",
"inviteMembersPageSubheading": "Пригласите сотрудников в компанию", "chooseDifferentRoleError": "Role is the same as the current one",
"createTeamModalHeading": "Создать компанию", "updateRole": "Update Role",
"createTeamModalDescription": "Создайте новую компанию для управления проектами и сотрудниками.", "updateRoleLoadingMessage": "Updating role...",
"teamNameLabel": "Название компании", "updateRoleSuccessMessage": "Role updated successfully",
"teamNameDescription": "Название вашей компании должно быть уникальным и описательным", "updatingRoleErrorMessage": "Sorry, we encountered an error. Please try again.",
"createTeamSubmitLabel": "Создать компанию", "updateMemberRoleModalHeading": "Update Member's Role",
"createTeamSuccess": "Компания успешно создана", "updateMemberRoleModalDescription": "Change the role of the selected member. The role determines the permissions of the member.",
"createTeamError": "Компания не создана. Пожалуйста, попробуйте снова.", "roleMustBeDifferent": "Role must be different from the current one",
"createTeamLoading": "Создание компании...", "memberRoleInputLabel": "Member role",
"settingsPageLabel": "Основное", "updateRoleDescription": "Pick a role for this member.",
"createTeamDropdownLabel": "Новая компания", "updateRoleSubmitLabel": "Update Role",
"changeRole": "Изменить роль", "transferOwnership": "Transfer Ownership",
"removeMember": "Удалить из аккаунта", "transferOwnershipDescription": "Transfer ownership of the company account to another member.",
"inviteMembersSuccess": "Сотрудники успешно приглашены!", "transferOwnershipInputLabel": "Please type TRANSFER to confirm the transfer of ownership.",
"inviteMembersError": "Произошла ошибка! Пожалуйста, попробуйте снова", "transferOwnershipInputDescription": "By transferring ownership, you will no longer be the primary admin of the company account.",
"inviteMembersLoading": "Приглашение сотрудников...", "deleteInvitation": "Delete Invitation",
"removeInviteButtonLabel": "Удалить приглашение", "deleteInvitationDialogDescription": "You are about to delete the invitation. The user will no longer be able to join the company account.",
"addAnotherMemberButtonLabel": "Добавить ещё одного", "deleteInviteSuccessMessage": "Invite deleted successfully",
"inviteMembersButtonLabel": "Отправить приглашения", "deleteInviteErrorMessage": "Invite not deleted. Please try again.",
"removeMemberModalHeading": "Вы удаляете этого пользователя", "deleteInviteLoadingMessage": "Deleting invite. Please wait...",
"removeMemberModalDescription": "Удалите этого сотрудника из компании. Он больше не будет иметь доступ к компании.", "confirmDeletingMemberInvite": "You are deleting the invite to <b>{{ email }}</b>",
"removeMemberSuccessMessage": "Сотрудник успешно удален", "transferOwnershipDisclaimer": "You are transferring ownership of the selected company account to <b>{{ member }}</b>.",
"removeMemberErrorMessage": "Произошла ошибка. Пожалуйста, попробуйте снова", "transferringOwnership": "Transferring ownership...",
"removeMemberErrorHeading": "Не удалось удалить выбранного сотрудника.", "transferOwnershipSuccess": "Ownership successfully transferred",
"removeMemberLoadingMessage": "Удаление сотрудника...", "transferOwnershipError": "Sorry, we could not transfer ownership to the selected member. Please try again.",
"removeMemberSubmitLabel": "Удалить пользователя из компании", "deleteInviteSubmitLabel": "Delete Invite",
"chooseDifferentRoleError": "Роль совпадает с текущей", "youBadgeLabel": "You",
"updateRole": "Обновить роль", "updateTeamLoadingMessage": "Updating Company...",
"updateRoleLoadingMessage": "Обновление роли...", "updateTeamSuccessMessage": "Company successfully updated",
"updateRoleSuccessMessage": "Роль успешно обновлена", "updateTeamErrorMessage": "Could not update Company. Please try again.",
"updatingRoleErrorMessage": "Произошла ошибка. Пожалуйста, попробуйте снова.", "updateLogoErrorMessage": "Could not update Logo. Please try again.",
"updateMemberRoleModalHeading": "Обновить роль сотрудника", "teamNameInputLabel": "Company Name",
"updateMemberRoleModalDescription": "Измените роль выбранного сотрудника. Роль определяет его права.", "teamLogoInputHeading": "Upload your company's Logo",
"roleMustBeDifferent": "Роль должна отличаться от текущей", "teamLogoInputSubheading": "Please choose a photo to upload as your company logo.",
"memberRoleInputLabel": "Роль сотрудника", "updateTeamSubmitLabel": "Update Company",
"updateRoleDescription": "Выберите роль для этого сотрудника.", "inviteMembersHeading": "Invite Members to your Company",
"updateRoleSubmitLabel": "Обновить роль", "inviteMembersDescription": "Invite member to your company by entering their email and role.",
"transferOwnership": "Передача собственности",
"transferOwnershipDescription": "Передайте права владельца аккаунта компании другому сотруднику.",
"transferOwnershipInputLabel": "Пожалуйста, введите TRANSFER для подтверждения передачи собственности.",
"transferOwnershipInputDescription": "Передав права собственности, вы больше не будете главным администратором компании.",
"deleteInvitation": "Удалить приглашение",
"deleteInvitationDialogDescription": "Вы собираетесь удалить приглашение. Пользователь больше не сможет присоединиться к аккаунту компании.",
"deleteInviteSuccessMessage": "Приглашение успешно удалено",
"deleteInviteErrorMessage": "Приглашение не удалено. Пожалуйста, попробуйте снова.",
"deleteInviteLoadingMessage": "Удаление приглашения...",
"confirmDeletingMemberInvite": "Вы удаляете приглашение для <b>{{ email }}</b>",
"transferOwnershipDisclaimer": "Вы передаете права собственности выбранного аккаунта компании <b>{{ member }}</b>.",
"transferringOwnership": "Передача собственности...",
"transferOwnershipSuccess": "Права собственности успешно переданы",
"transferOwnershipError": "Не удалось передать права собственности выбранному сотруднику. Пожалуйста, попробуйте снова.",
"deleteInviteSubmitLabel": "Удалить приглашение",
"youBadgeLabel": "Вы",
"updateTeamLoadingMessage": "Обновление компании...",
"updateTeamSuccessMessage": "Компания успешно обновлена",
"updateTeamErrorMessage": "Не удалось обновить компанию. Пожалуйста, попробуйте снова.",
"updateLogoErrorMessage": "Не удалось обновить логотип. Пожалуйста, попробуйте снова.",
"teamNameInputLabel": "Название компании",
"teamLogoInputHeading": "Загрузите логотип вашей компании",
"teamLogoInputSubheading": "Выберите фото для загрузки в качестве логотипа компании.",
"updateTeamSubmitLabel": "Обновить компанию",
"inviteMembersHeading": "Пригласить сотрудников в компанию",
"inviteMembersDescription": "Пригласите сотрудника в компанию, указав его email и роль.",
"emailPlaceholder": "member@email.com", "emailPlaceholder": "member@email.com",
"membersPageHeading": "Сотрудники", "membersPageHeading": "Members",
"inviteMembersButton": "Пригласить сотрудников", "inviteMembersButton": "Invite Members",
"invitingMembers": "Приглашение сотрудников...", "invitingMembers": "Inviting members...",
"inviteMembersSuccessMessage": "Сотрудники успешно приглашены", "inviteMembersSuccessMessage": "Members invited successfully",
"inviteMembersErrorMessage": "Не удалось пригласить сотрудников. Пожалуйста, попробуйте снова.", "inviteMembersErrorMessage": "Sorry, members could not be invited. Please try again.",
"pendingInvitesHeading": "Ожидающие приглашения", "pendingInvitesHeading": "Pending Invites",
"pendingInvitesDescription": "Здесь можно управлять ожидающими приглашениями в вашу компанию.", "pendingInvitesDescription": " Here you can manage the pending invitations to your company.",
"noPendingInvites": "Нет ожидающих приглашений", "noPendingInvites": "No pending invites found",
"loadingMembers": "Загрузка сотрудников...", "loadingMembers": "Loading members...",
"loadMembersError": "Не удалось получить список сотрудников компании.", "loadMembersError": "Sorry, we couldn't fetch your company's members.",
"loadInvitedMembersError": "Не удалось получить список приглашённых сотрудников компании.", "loadInvitedMembersError": "Sorry, we couldn't fetch your company's invited members.",
"loadingInvitedMembers": "Загрузка приглашённых сотрудников...", "loadingInvitedMembers": "Loading invited members...",
"invitedBadge": "Приглашён", "invitedBadge": "Invited",
"duplicateInviteEmailError": "Вы уже добавили этот адрес электронной почты", "duplicateInviteEmailError": "You have already entered this email address",
"invitingOwnAccountError": "Эй, это ваш email!", "invitingOwnAccountError": "Hey, that's your email!",
"dangerZone": "Опасная зона", "dangerZone": "Danger Zone",
"dangerZoneSubheading": "Удалить или покинуть компанию", "dangerZoneSubheading": "Delete or leave your company",
"deleteTeam": "Удалить компанию", "deleteTeam": "Delete Company",
"deleteTeamDescription": "Это действие невозможно отменить. Все данные, связанные с этой компанией, будут удалены.", "deleteTeamDescription": "This action cannot be undone. All data associated with this company will be deleted.",
"deletingTeam": "Удаление компании...", "deletingTeam": "Deleting company",
"deleteTeamModalHeading": "Удаление компании", "deleteTeamModalHeading": "Deleting Company",
"deletingTeamDescription": "Вы собираетесь удалить компанию {{ teamName }}. Это действие невозможно отменить.", "deletingTeamDescription": "You are about to delete the company {{ teamName }}. This action cannot be undone.",
"deleteTeamInputField": "Введите название компании для подтверждения", "deleteTeamInputField": "Type the name of the company to confirm",
"leaveTeam": "Покинуть компанию", "leaveTeam": "Leave Company",
"leavingTeamModalHeading": "Покидание компании", "leavingTeamModalHeading": "Leaving Company",
"leavingTeamModalDescription": "Вы собираетесь покинуть эту компанию. У вас больше не будет к ней доступа.", "leavingTeamModalDescription": "You are about to leave this company. You will no longer have access to it.",
"leaveTeamDescription": "Нажмите кнопку ниже, чтобы покинуть компанию. Помните, что вы больше не будете иметь к ней доступа и вам потребуется повторное приглашение для присоединения.", "leaveTeamDescription": "Click the button below to leave the company. Remember, you will no longer have access to it and will need to be re-invited to join.",
"deleteTeamDisclaimer": "Вы удаляете компанию {{ teamName }}. Это действие невозможно отменить.", "deleteTeamDisclaimer": "You are deleting the company {{ teamName }}. This action cannot be undone.",
"leaveTeamDisclaimer": "Вы покидаете компанию {{ teamName }}. У вас больше не будет к ней доступа.", "leaveTeamDisclaimer": "You are leaving the company {{ teamName }}. You will no longer have access to it.",
"deleteTeamErrorHeading": "Не удалось удалить вашу компанию.", "deleteTeamErrorHeading": "Sorry, we couldn't delete your company.",
"leaveTeamErrorHeading": "Не удалось покинуть вашу компанию.", "leaveTeamErrorHeading": "Sorry, we couldn't leave your company.",
"searchMembersPlaceholder": "Поиск сотрудников", "searchMembersPlaceholder": "Search members",
"createTeamErrorHeading": "Не удалось создать вашу компанию.", "createTeamErrorHeading": "Sorry, we couldn't create your company.",
"createTeamErrorMessage": "Произошла ошибка при создании компании. Пожалуйста, попробуйте снова.", "createTeamErrorMessage": "We encountered an error creating your company. Please try again.",
"transferTeamErrorHeading": "Не удалось передать права собственности аккаунта компании.", "transferTeamErrorHeading": "Sorry, we couldn't transfer ownership of your company account.",
"transferTeamErrorMessage": "Произошла ошибка при передаче прав собственности аккаунта компании. Пожалуйста, попробуйте снова.", "transferTeamErrorMessage": "We encountered an error transferring ownership of your company account. Please try again.",
"updateRoleErrorHeading": "Не удалось обновить роль выбранного сотрудника.", "updateRoleErrorHeading": "Sorry, we couldn't update the role of the selected member.",
"updateRoleErrorMessage": "Произошла ошибка при обновлении роли выбранного сотрудника. Пожалуйста, попробуйте снова.", "updateRoleErrorMessage": "We encountered an error updating the role of the selected member. Please try again.",
"searchInvitations": "Поиск приглашений", "searchInvitations": "Search Invitations",
"updateInvitation": "Обновить приглашение", "updateInvitation": "Update Invitation",
"removeInvitation": "Удалить приглашение", "removeInvitation": "Remove Invitation",
"acceptInvitation": "Принять приглашение", "acceptInvitation": "Accept Invitation",
"renewInvitation": "Продлить приглашение", "renewInvitation": "Renew Invitation",
"resendInvitation": "Отправить приглашение повторно", "resendInvitation": "Resend Invitation",
"expiresAtLabel": "Истекает", "expiresAtLabel": "Expires at",
"expired": "Истекло", "expired": "Expired",
"active": "Активно", "active": "Active",
"inviteStatus": "Статус", "inviteStatus": "Status",
"inviteNotFoundOrExpired": "Приглашение не найдено или истекло", "inviteNotFoundOrExpired": "Invite not found or expired",
"inviteNotFoundOrExpiredDescription": "Приглашение, которое вы ищете, либо истекло, либо не существует. Пожалуйста, свяжитесь с администратором компании для продления приглашения.", "inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the company admin to renew the invite.",
"backToHome": "Назад на главную", "backToHome": "Back to Home",
"renewInvitationDialogDescription": "Вы собираетесь продлить приглашение для {{ email }}. Пользователь сможет присоединиться к компании.", "renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the company.",
"renewInvitationErrorTitle": "Не удалось продлить приглашение.", "renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.",
"renewInvitationErrorDescription": "Произошла ошибка при продлении приглашения. Пожалуйста, попробуйте снова.", "renewInvitationErrorDescription": "We encountered an error renewing the invitation. Please try again.",
"signInWithDifferentAccount": "Войти с другим аккаунтом", "signInWithDifferentAccount": "Sign in with a different account",
"signInWithDifferentAccountDescription": "Если вы хотите принять приглашение с другим аккаунтом, выйдите из системы и войдите с нужным аккаунтом.", "signInWithDifferentAccountDescription": "If you wish to accept the invitation with a different account, please sign out and back in with the account you wish to use.",
"acceptInvitationHeading": "Принять приглашение для присоединения к {{accountName}}", "acceptInvitationHeading": "Accept Invitation to join {{accountName}}",
"acceptInvitationDescription": "Вас пригласили присоединиться к компании {{accountName}}. Чтобы принять приглашение, нажмите кнопку ниже.", "acceptInvitationDescription": "You have been invited to join the company {{accountName}}. If you wish to accept the invitation, please click the button below.",
"continueAs": "Продолжить как {{email}}", "continueAs": "Continue as {{email}}",
"joinTeamAccount": "Присоединиться к компании", "joinTeamAccount": "Join Company",
"joiningTeam": "Присоединение к компании...", "joiningTeam": "Joining company...",
"leaveTeamInputLabel": "Пожалуйста, введите LEAVE для подтверждения выхода из компании.", "leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.",
"leaveTeamInputDescription": "При выходе из компании у вас больше не будет к ней доступа.", "leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.",
"reservedNameError": "Это имя зарезервировано. Пожалуйста, выберите другое.", "reservedNameError": "This name is reserved. Please choose a different one.",
"specialCharactersError": "Это имя не может содержать специальные символы. Пожалуйста, выберите другое.", "specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
"personalCode": "Идентификационный код", "personalCode": "Личный код"
"teamOwnerPersonalCodeLabel": "Идентификационный код владельца"
} }

View File

@@ -135,7 +135,6 @@
--animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out;
--breakpoint-2xs: 36rem;
--breakpoint-xs: 48rem; --breakpoint-xs: 48rem;
--breakpoint-sm: 64rem; --breakpoint-sm: 64rem;
--breakpoint-md: 70rem; --breakpoint-md: 70rem;

View File

@@ -269,6 +269,14 @@ url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. # If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false skip_nonce_check = false
[auth.external.keycloak]
enabled = true
client_id = "env(SUPABASE_AUTH_CLIENT_ID)"
secret = "env(SUPABASE_AUTH_KEYCLOAK_SECRET)"
redirect_uri = "env(SUPABASE_AUTH_KEYCLOAK_CALLBACK_URL)"
url = "env(SUPABASE_AUTH_KEYCLOAK_URL)"
skip_nonce_check = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth. # Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase] [auth.third_party.firebase]
enabled = false enabled = false

View File

@@ -1,17 +0,0 @@
create extension if not exists pg_cron;
create extension if not exists pg_net;
select
cron.schedule(
'send emails with new unassigned jobs 4x a day',
'0 4,9,14,18 * * 1-5', -- Run at 07:00, 12:00, 17:00 and 21:00 (GMT +3) on weekdays only
$$
select
net.http_post(
url := 'https://test.medreport.ee/api/job/send-open-jobs-emails',
headers := jsonb_build_object(
'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84'
)
) as request_id;
$$
);

View File

@@ -1,8 +0,0 @@
grant select on table "medreport"."doctor_analysis_feedback" to "service_role";
create policy "service_role_select"
on "medreport"."doctor_analysis_feedback"
as permissive
for select
to service_role
using (true);

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