46 Commits

Author SHA1 Message Date
aa441d4055 update medreport.accounts with keycloak data when supabase auth.user is created 2025-09-08 01:05:02 +03:00
c882a24415 update envs for keycloak 2025-09-08 01:04:37 +03:00
077aaee181 handle keycloak user prefills in update-account form 2025-09-08 01:03:17 +03:00
57a998d215 fix NaN for bmi when divide-by-zero 2025-09-08 01:02:42 +03:00
f01829de96 update keycloak signup / login 2025-09-08 01:02:10 +03:00
7815a1c011 add phone number validation to update account form 2025-09-08 00:59:17 +03:00
96eea95fb9 fix password signup not redirecting to update-account view 2025-09-08 00:58:34 +03:00
6495d1c4a3 fix toTitleCase 2025-09-08 00:58:02 +03:00
a9612ad992 remove useless await 2025-09-08 00:57:38 +03:00
ab2176bc69 fix analyses loading 2025-09-08 00:57:28 +03:00
a89d8d3153 fix whitespace 2025-09-08 00:57:18 +03:00
1b29cb222b prefer pathsConfig urls 2025-09-08 00:56:55 +03:00
dfcfdb8f97 Merge pull request #76 from MR-medreport/improvements-0609
update order xml for live, allow adding discounts in cart
2025-09-06 19:58:59 +00:00
d87d08aaea Merge pull request #75 from MR-medreport/main
main <-> develop
2025-09-06 19:57:59 +00:00
c83694222d allow adding discounts in cart 2025-09-06 22:56:54 +03:00
c08fe26b36 remove comments from order xml 2025-09-05 15:13:14 +03:00
d3202a2cb2 remove comments from order xml 2025-09-05 15:12:58 +03:00
2435e6f113 update medipost order xml for live 2025-09-05 15:09:27 +03:00
54856b0e45 update medipost order xml for live 2025-09-05 15:09:11 +03:00
95e72bb3f8 log out of medusa and reset cart on supabase logout 2025-09-05 14:15:03 +03:00
3d268b6061 retry initial dockerfile 2025-09-05 14:09:02 +03:00
5c6280ec42 retry updated dockerfile 2025-09-05 14:01:22 +03:00
0de9dcf7e3 retry 2025-09-05 13:49:52 +03:00
f3a6fb627c react compiler uses too much memory 2025-09-05 13:39:04 +03:00
c1746c6c20 retry 2025-09-05 13:20:17 +03:00
c6f56f6e11 retry pipeline with updated parameters 2025-09-05 12:54:58 +03:00
a6b246cdf3 improve dockerfile 2025-09-05 12:52:06 +03:00
a705dea9cf retry 2025-09-05 12:19:00 +03:00
8485d2e9a3 retry 2025-09-05 12:11:34 +03:00
c356f69656 retry 2025-09-05 12:03:26 +03:00
cacd23be40 layer cache before envs 2025-09-05 11:53:39 +03:00
f8765dce49 update dockerfile 2025-09-05 11:44:25 +03:00
42bebb6d93 all needed variables in buildtime 2025-09-05 11:39:12 +03:00
354a0c04ee prefer to use env from parameter store 2025-09-05 01:44:05 +03:00
72bb9a33ef Merge branch 'develop' 2025-09-05 01:39:25 +03:00
771c28f8ef rerun codepipeline 2025-09-04 17:07:52 +03:00
Danel Kungla
e9497c3d52 update staging env 2025-09-04 14:26:21 +03:00
70188f297f Merge pull request #73 from MR-medreport/develop
develop -> main
2025-09-04 10:42:33 +00:00
e0940a1600 allow transferCart to fail on register 2025-09-04 13:41:21 +03:00
65eb6c780d allow transferCart to fail on login/register 2025-09-04 13:20:12 +03:00
6e9cde6b95 medusa product can have either analysiselement or analysis originalId 2025-09-04 13:20:09 +03:00
3a062eaa9c hide dashboard recommendations block 2025-09-04 13:20:05 +03:00
c07acb85a2 fix tooltip should wrap long text 2025-09-04 13:20:02 +03:00
1de564b917 rerun codepipeline 2025-09-03 15:15:02 +03:00
a2c080914a Merge branch 'develop' 2025-09-03 13:32:15 +03:00
Danel Kungla
94dd00b9ca Add data.sql to .gitignore 2025-08-29 18:05:29 +03:00
62 changed files with 1069 additions and 546 deletions

5
.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=true NEXT_PUBLIC_AUTH_PASSWORD=false
NEXT_PUBLIC_AUTH_MAGIC_LINK=false NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY= NEXT_PUBLIC_CAPTCHA_SITE_KEY=
@@ -65,3 +65,6 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
# Configure Medusa password secret for Keycloak users
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==

View File

@@ -3,6 +3,7 @@
# SITE # SITE
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_AUTH_PASSWORD=true
# SUPABASE DEVELOPMENT # SUPABASE DEVELOPMENT

View File

@@ -6,10 +6,10 @@
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE. ## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
# SUPABASE # SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co # NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0 # NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee # NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# MONTONIO # # MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead # NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

View File

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

View File

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

View File

@@ -13,10 +13,7 @@ import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { featureFlagsConfig } from '@kit/shared/config'; import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { pathsConfig } from '@kit/shared/config';
const ModeToggle = dynamic(() => const ModeToggle = dynamic(() =>
import('@kit/ui/mode-toggle').then((mod) => ({ import('@kit/ui/mode-toggle').then((mod) => ({
@@ -75,11 +72,13 @@ function AuthButtons() {
</Link> </Link>
</Button> </Button>
<Button asChild className="text-xs md:text-sm" variant={'default'}> {authConfig.providers.password && (
<Link href={pathsConfig.auth.signUp}> <Button asChild className="text-xs md:text-sm" variant={'default'}>
<Trans i18nKey={'auth:signUp'} /> <Link href={pathsConfig.auth.signUp}>
</Link> <Trans i18nKey={'auth:signUp'} />
</Button> </Link>
</Button>
)}
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
import Link from 'next/link';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { authConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
export default function PasswordOption({
inviteToken,
returnPath,
}: {
inviteToken?: string;
returnPath?: string;
}) {
const signUpPath =
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
const paths = {
callback: pathsConfig.auth.callback,
returnPath: returnPath ?? pathsConfig.app.home,
joinTeam: pathsConfig.app.joinTeam,
updateAccount: pathsConfig.auth.updateAccount,
};
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth:signInHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth:signInSubheading'} />
</p>
</div>
<SignInMethodsContainer
inviteToken={inviteToken}
paths={paths}
providers={authConfig.providers}
/>
<div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}>
<Link href={signUpPath} prefetch={true}>
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
</Link>
</Button>
</div>
</>
);
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpMethodsContainer } from '@kit/auth/sign-up'; import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { authConfig, pathsConfig } from '@kit/shared/config'; import { authConfig, pathsConfig } from '@kit/shared/config';
@@ -37,6 +38,10 @@ async function SignUpPage({ searchParams }: Props) {
pathsConfig.auth.signIn + pathsConfig.auth.signIn +
(inviteToken ? `?invite_token=${inviteToken}` : ''); (inviteToken ? `?invite_token=${inviteToken}` : '');
if (!authConfig.providers.password) {
return redirect('/');
}
return ( return (
<> <>
<div className={'flex flex-col items-center gap-1'}> <div className={'flex flex-col items-center gap-1'}>

View File

@@ -2,8 +2,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { User } from '@supabase/supabase-js';
import { ExternalLink } from '@/public/assets/external-link'; import { ExternalLink } from '@/public/assets/external-link';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -23,31 +21,52 @@ import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema'; import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
import { onUpdateAccount } from '../_lib/server/update-account'; import { onUpdateAccount } from '../_lib/server/update-account';
import { z } from 'zod';
export function UpdateAccountForm({ user }: { user: User }) { type UpdateAccountFormValues = z.infer<typeof UpdateAccountSchema>;
export function UpdateAccountForm({
defaultValues,
}: {
defaultValues: UpdateAccountFormValues,
}) {
const form = useForm({ const form = useForm({
resolver: zodResolver(UpdateAccountSchema), resolver: zodResolver(UpdateAccountSchema),
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues,
firstName: '',
lastName: '',
personalCode: '',
email: user.email,
phone: '',
city: '',
weight: 0,
height: 0,
userConsent: false,
},
}); });
const { firstName, lastName, personalCode, email, weight, height, userConsent } = defaultValues;
const hasFirstName = !!firstName;
const hasLastName = !!lastName;
const hasPersonalCode = !!personalCode;
const hasEmail = !!email;
const hasWeight = !!weight;
const hasHeight = !!height;
const hasUserConsent = !!userConsent;
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) =>
onUpdateAccount({
...values,
...(hasFirstName && { firstName }),
...(hasLastName && { lastName }),
...(hasPersonalCode && { personalCode }),
...(hasEmail && { email }),
...(hasWeight && { weight: values.weight ?? weight }),
...(hasHeight && { height: values.height ?? height }),
...(hasUserConsent && { userConsent: values.userConsent ?? userConsent }),
});
return ( return (
<Form {...form}> <Form {...form}>
<form <form
className="flex flex-col gap-6 px-6 pt-10 text-left" className="flex flex-col gap-6 px-6 pt-10 text-left"
onSubmit={form.handleSubmit(onUpdateAccount)} onSubmit={form.handleSubmit(onUpdateAccountOptions)}
> >
<FormField <FormField
name="firstName" name="firstName"
disabled={hasFirstName}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
@@ -63,6 +82,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField <FormField
name="lastName" name="lastName"
disabled={hasLastName}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
@@ -78,6 +98,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField <FormField
name="personalCode" name="personalCode"
disabled={hasPersonalCode}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
@@ -93,13 +114,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField <FormField
name="email" name="email"
disabled={hasEmail}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:email'} /> <Trans i18nKey={'common:formField:email'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} disabled /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import parsePhoneNumber from 'libphonenumber-js/min';
export const UpdateAccountSchema = z.object({ export const UpdateAccountSchema = z.object({
firstName: z firstName: z
@@ -23,7 +24,20 @@ export const UpdateAccountSchema = z.object({
.string({ .string({
error: 'Phone number is required', error: 'Phone number is required',
}) })
.nonempty(), .nonempty()
.refine(
(phone) => {
try {
const phoneNumber = parsePhoneNumber(phone);
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPhoneNumber',
}
),
city: z.string().optional(), city: z.string().optional(),
weight: z weight: z
.number({ .number({

View File

@@ -28,11 +28,15 @@ export const onUpdateAccount = enhanceAction(
console.warn('On update account error: ', err); console.warn('On update account error: ', err);
} }
await updateCustomer({ try {
first_name: params.firstName, await updateCustomer({
last_name: params.lastName, first_name: params.firstName,
phone: params.phone, last_name: params.lastName,
}); phone: params.phone,
});
} catch (e) {
console.error("Failed to update Medusa customer", e);
}
const hasUnseenMembershipConfirmation = const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation(); await api.hasUnseenMembershipConfirmation();

View File

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

View File

@@ -8,7 +8,7 @@ import { listProductTypes } from "@lib/data/products";
import { placeOrder, retrieveCart } from "@lib/data/cart"; import { placeOrder, retrieveCart } from "@lib/data/cart";
import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
import { createOrder } from '~/lib/services/order.service'; import { createOrder } from '~/lib/services/order.service';
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
import { createNotificationsApi } from '@kit/notifications/api'; import { createNotificationsApi } from '@kit/notifications/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { AccountWithParams } from '@kit/accounts/api'; import { AccountWithParams } from '@kit/accounts/api';
@@ -114,7 +114,7 @@ export async function processMontonioCallback(orderToken: string) {
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false }); const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder }); const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements }); const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
const { productTypes } = await listProductTypes(); const { productTypes } = await listProductTypes();

View File

@@ -27,7 +27,7 @@ async function UserHomePage() {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const account = await loadCurrentUserAccount(); const account = await loadCurrentUserAccount();
const api = await createAccountsApi(client); const api = createAccountsApi(client);
const bmiThresholds = await api.fetchBmiThresholds(); const bmiThresholds = await api.fetchBmiThresholds();
if (!account) { if (!account) {

View File

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

View File

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

View File

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

View File

@@ -9,20 +9,17 @@ import {
Activity, Activity,
ChevronRight, ChevronRight,
Clock9, Clock9,
Droplets,
Pill, Pill,
Scale, Scale,
TrendingUp, TrendingUp,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { getPersonParameters } from '@kit/shared/utils'; import { getPersonParameters } from '@kit/shared/utils';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
Card, Card,
CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
@@ -38,6 +35,7 @@ import {
getBmiBackgroundColor, getBmiBackgroundColor,
getBmiStatus, getBmiStatus,
} from '~/lib/utils'; } from '~/lib/utils';
import DashboardRecommendations from './dashboard-recommendations';
const getCardVariant = (isSuccess: boolean | null): CardProps['variant'] => { const getCardVariant = (isSuccess: boolean | null): CardProps['variant'] => {
if (isSuccess === null) return 'default'; if (isSuccess === null) return 'default';
@@ -86,7 +84,7 @@ const cards = ({
}, },
{ {
title: 'dashboard:bmi', title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(), description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
icon: <TrendingUp />, icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus), iconBg: getBmiBackgroundColor(bmiStatus),
}, },
@@ -135,37 +133,7 @@ const cards = ({
}, },
]; ];
const dummyRecommendations = [ const IS_SHOWN_RECOMMENDATIONS = false as boolean;
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-cyan/10 text-cyan',
title: 'Kolesterooli kontroll',
description: 'HDL-kolestrool',
tooltipContent: 'Selgitus',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-primary/10 text-primary',
title: 'Kolesterooli kontroll',
tooltipContent: 'Selgitus',
description: 'LDL-Kolesterool',
buttonText: 'Broneeri',
href: '/home/booking',
},
{
icon: <Droplets />,
color: 'bg-destructive/10 text-destructive',
title: 'Vererõhu kontroll',
tooltipContent: 'Selgitus',
description: 'Score-Risk 2',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
];
export default function Dashboard({ export default function Dashboard({
account, account,
@@ -232,79 +200,7 @@ export default function Dashboard({
), ),
)} )}
</div> </div>
<Card> {IS_SHOWN_RECOMMENDATIONS && <DashboardRecommendations />}
<CardHeader className="items-start">
<h4>
<Trans i18nKey="dashboard:recommendedForYou" />
</h4>
</CardHeader>
<CardContent className="space-y-6">
{dummyRecommendations.map(
(
{
icon,
color,
title,
description,
tooltipContent,
price,
buttonText,
href,
},
index,
) => {
return (
<div
className="flex w-full justify-between gap-3 overflow-scroll"
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
color,
)}
>
{icon}
</div>
<div className="min-w-fit">
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title}
<InfoTooltip content={tooltipContent} />
</div>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
</div>
<div className="grid w-36 min-w-fit auto-rows-fr grid-cols-2 items-center gap-4">
<p className="text-sm font-medium"> {price}</p>
{href ? (
<Link href={href}>
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
</Link>
) : (
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
)}
</div>
</div>
);
},
)}
</CardContent>
</Card>
</> </>
); );
} }

View File

@@ -94,7 +94,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" />} {variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
</Button> </Button>
</div> </div>
)} )}

View File

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

View File

@@ -3,9 +3,26 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { createClient } from '@/utils/supabase/server'; import { createClient } from '@/utils/supabase/server';
import { medusaLogout } from '@lib/data/customer';
export const signOutAction = async () => { export const signOutAction = async () => {
const supabase = await createClient(); const client = await createClient();
await supabase.auth.signOut();
try {
try {
await medusaLogout();
} catch (medusaError) {
console.warn('Medusa logout failed or not available:', medusaError);
}
const { error } = await client.auth.signOut();
if (error) {
throw error;
}
} catch (error) {
console.error('Logout error:', error);
throw error;
}
return redirect('/'); return redirect('/');
}; };

View File

@@ -105,12 +105,18 @@ export const createMedusaSyncSuccessEntry = async () => {
}); });
} }
export async function getAnalyses({ ids }: { ids: number[] }): Promise<AnalysesWithGroupsAndElements> { export async function getAnalyses({ ids, originalIds }: { ids?: number[], originalIds?: string[] }): Promise<AnalysesWithGroupsAndElements> {
const { data } = await getSupabaseServerAdminClient() const query = getSupabaseServerAdminClient()
.schema('medreport') .schema('medreport')
.from('analyses') .from('analyses')
.select(`*, analysis_elements(*, analysis_groups(*))`) .select(`*, analysis_elements(*, analysis_groups(*))`);
.in('id', ids); if (Array.isArray(ids)) {
query.in('id', ids);
}
if (Array.isArray(originalIds)) {
query.in('analysis_id_original', originalIds);
}
const { data } = await query.throwOnError();
return data as unknown as AnalysesWithGroupsAndElements; return data as unknown as AnalysesWithGroupsAndElements;
} }

View File

@@ -10,6 +10,7 @@ import {
getClientInstitution, getClientInstitution,
getClientPerson, getClientPerson,
getConfidentiality, getConfidentiality,
getOrderEnteredPerson,
getPais, getPais,
getPatient, getPatient,
getProviderInstitution, getProviderInstitution,
@@ -553,14 +554,12 @@ export async function composeOrderXML({
${getPais(USER, RECIPIENT, orderCreatedAt, orderId)} ${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
<Tellimus cito="EI"> <Tellimus cito="EI">
<ValisTellimuseId>${orderId}</ValisTellimuseId> <ValisTellimuseId>${orderId}</ValisTellimuseId>
<!--<TellijaAsutus>-->
${getClientInstitution()} ${getClientInstitution()}
<!--<TeostajaAsutus>-->
${getProviderInstitution()} ${getProviderInstitution()}
<!--<TellijaIsik>--> ${getClientPerson()}
${getClientPerson(person)} ${getOrderEnteredPerson()}
<TellijaMarkused>${comment ?? ''}</TellijaMarkused> <TellijaMarkused>${comment ?? ''}</TellijaMarkused>
${getPatient(person)} ${getPatient(person)}
${getConfidentiality()} ${getConfidentiality()}
${specimenSection.join('')} ${specimenSection.join('')}
${analysisSection?.join('')} ${analysisSection?.join('')}
@@ -666,7 +665,7 @@ async function syncPrivateMessage({
unit: element.Mootyhik ?? null, unit: element.Mootyhik ?? null,
original_response_element: element, original_response_element: element,
analysis_name: element.UuringNimi || element.KNimetus, analysis_name: element.UuringNimi || element.KNimetus,
comment: element.UuringuKommentaar comment: element.UuringuKommentaar ?? '',
})), })),
); );
} }
@@ -715,7 +714,7 @@ export async function sendOrderToMedipost({
orderedAnalysisElements, orderedAnalysisElements,
}: { }: {
medusaOrderId: string; medusaOrderId: string;
orderedAnalysisElements: { analysisElementId: number }[]; orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
}) { }) {
const medreportOrder = await getOrder({ medusaOrderId }); const medreportOrder = await getOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
@@ -727,8 +726,8 @@ export async function sendOrderToMedipost({
lastName: account.last_name ?? '', lastName: account.last_name ?? '',
phone: account.phone ?? '', phone: account.phone ?? '',
}, },
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId), orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: [], orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId, orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at), orderCreatedAt: new Date(medreportOrder.created_at),
comment: '', comment: '',
@@ -784,12 +783,13 @@ export async function sendOrderToMedipost({
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
} }
export async function getOrderedAnalysisElementsIds({ export async function getOrderedAnalysisIds({
medusaOrder, medusaOrder,
}: { }: {
medusaOrder: StoreOrder; medusaOrder: StoreOrder;
}): Promise<{ }): Promise<{
analysisElementId: number; analysisElementId?: number;
analysisId?: number;
}[]> { }[]> {
const countryCodes = await listRegions(); const countryCodes = await listRegions();
const countryCode = countryCodes[0]!.countries![0]!.iso_2!; const countryCode = countryCodes[0]!.countries![0]!.iso_2!;
@@ -802,6 +802,14 @@ export async function getOrderedAnalysisElementsIds({
return analysisElements.map(({ id }) => ({ analysisElementId: id })); return analysisElements.map(({ id }) => ({ analysisElementId: id }));
} }
async function getOrderedAnalyses(medusaOrder: StoreOrder) {
const originalIds = (medusaOrder?.items ?? [])
.map((a) => a.product?.metadata?.analysisIdOriginal)
.filter((a) => typeof a === 'string') as string[];
const analyses = await getAnalyses({ originalIds });
return analyses.map(({ id }) => ({ analysisId: id }));
}
async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) { async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) {
const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX)); const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX));
const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[]; const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[];
@@ -841,12 +849,13 @@ export async function getOrderedAnalysisElementsIds({
return analysisElements.map(({ id }) => ({ analysisElementId: id })); return analysisElements.map(({ id }) => ({ analysisElementId: id }));
} }
const [analysisPackageElements, orderedAnalysisElements] = await Promise.all([ const [analysisPackageElements, orderedAnalysisElements, orderedAnalyses] = await Promise.all([
getOrderedAnalysisPackages(medusaOrder), getOrderedAnalysisPackages(medusaOrder),
getOrderedAnalysisElements(medusaOrder), getOrderedAnalysisElements(medusaOrder),
getOrderedAnalyses(medusaOrder),
]); ]);
return [...analysisPackageElements, ...orderedAnalysisElements]; return [...analysisPackageElements, ...orderedAnalysisElements, ...orderedAnalyses];
} }
export async function createMedipostActionLog({ export async function createMedipostActionLog({

View File

@@ -3,6 +3,7 @@
import { import {
getClientInstitution, getClientInstitution,
getClientPerson, getClientPerson,
getOrderEnteredPerson,
getPais, getPais,
getPatient, getPatient,
getProviderInstitution, getProviderInstitution,
@@ -104,7 +105,8 @@ export async function composeOrderTestResponseXML({
<ValisTellimuseId>${orderId}</ValisTellimuseId> <ValisTellimuseId>${orderId}</ValisTellimuseId>
${getClientInstitution({ index: 1 })} ${getClientInstitution({ index: 1 })}
${getProviderInstitution({ index: 1 })} ${getProviderInstitution({ index: 1 })}
${getClientPerson(person)} ${getClientPerson()}
${getOrderEnteredPerson()}
<TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused> <TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused>
${getPatient(person)} ${getPatient(person)}

View File

@@ -10,7 +10,7 @@ export async function createOrder({
orderedAnalysisElements, orderedAnalysisElements,
}: { }: {
medusaOrder: StoreOrder; medusaOrder: StoreOrder;
orderedAnalysisElements: { analysisElementId: number }[]; orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
}) { }) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
@@ -21,8 +21,8 @@ export async function createOrder({
const orderResult = await supabase.schema('medreport') const orderResult = await supabase.schema('medreport')
.from('analysis_orders') .from('analysis_orders')
.insert({ .insert({
analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId), analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
analysis_ids: [], analysis_ids: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
status: 'QUEUED', status: 'QUEUED',
user_id: user.id, user_id: user.id,
medusa_order_id: medusaOrder.id, medusa_order_id: medusaOrder.id,

View File

@@ -21,70 +21,48 @@ export const getPais = (
<Saaja>${recipient}</Saaja> <Saaja>${recipient}</Saaja>
<Aeg>${format(createdAt, DATE_TIME_FORMAT)}</Aeg> <Aeg>${format(createdAt, DATE_TIME_FORMAT)}</Aeg>
<SaadetisId>${orderId}</SaadetisId> <SaadetisId>${orderId}</SaadetisId>
<Email>argo@medreport.ee</Email> <Email>info@medreport.ee</Email>
</Pais>`; </Pais>`;
}; };
export const getClientInstitution = ({ index }: { index?: number } = {}) => { export const getClientInstitution = ({ index }: { index?: number } = {}) => {
if (isProd) {
// return correct data
}
return `<Asutus tyyp="TELLIJA" ${index ? ` jarjenumber="${index}"` : ''}> return `<Asutus tyyp="TELLIJA" ${index ? ` jarjenumber="${index}"` : ''}>
<AsutuseId>16381793</AsutuseId> <AsutuseId>16381793</AsutuseId>
<AsutuseNimi>MedReport OÜ</AsutuseNimi> <AsutuseNimi>MedReport OÜ</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood> <AsutuseKood>MRP</AsutuseKood>
<Telefon>+37258871517</Telefon> <Telefon>+37258871517</Telefon>
</Asutus>`; </Asutus>`;
}; };
export const getProviderInstitution = ({ index }: { index?: number } = {}) => { export const getProviderInstitution = ({ index }: { index?: number } = {}) => {
if (isProd) {
// return correct data
}
return `<Asutus tyyp="TEOSTAJA" ${index ? ` jarjenumber="${index}"` : ''}> return `<Asutus tyyp="TEOSTAJA" ${index ? ` jarjenumber="${index}"` : ''}>
<AsutuseId>11107913</AsutuseId> <AsutuseId>11107913</AsutuseId>
<AsutuseNimi>Synlab HTI Tallinn</AsutuseNimi> <AsutuseNimi>Synlab Eesti OÜ</AsutuseNimi>
<AsutuseKood>SLA</AsutuseKood> <AsutuseKood>HTI</AsutuseKood>
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi> <AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
<Telefon>+3723417123</Telefon> <Telefon>+37217123</Telefon>
</Asutus>`; </Asutus>`;
}; };
export const getClientPerson = ({ export const getClientPerson = () => {
idCode,
firstName,
lastName,
phone,
}: {
idCode: string,
firstName: string,
lastName: string,
phone: string,
}) => {
if (isProd) {
// return correct data
}
return `<Personal tyyp="TELLIJA" jarjenumber="1"> return `<Personal tyyp="TELLIJA" jarjenumber="1">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID> <PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>${idCode}</PersonalKood> <PersonalKood>D07907</PersonalKood>
<PersonalPerekonnaNimi>${lastName}</PersonalPerekonnaNimi> <PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
<PersonalEesNimi>${firstName}</PersonalEesNimi> <PersonalEesNimi>Tsvetkov</PersonalEesNimi>
${phone ? `<Telefon>${phone.startsWith('+372') ? phone : `+372${phone}`}</Telefon>` : ''} <Telefon>+37258131202</Telefon>
</Personal>`; </Personal>`;
}; };
// export const getOrderEnteredPerson = () => { export const getOrderEnteredPerson = () => {
// if (isProd) { return `<Personal tyyp="SISESTAJA" jarjenumber="2">
// // return correct data <PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
// } <PersonalKood>D07907</PersonalKood>
// return `<Personal tyyp="SISESTAJA" jarjenumber="1"> <PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
// <PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID> <PersonalEesNimi>Tsvetkov</PersonalEesNimi>
// <PersonalKood>D07907</PersonalKood> <Telefon>+37258131202</Telefon>
// <PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi> </Personal>`;
// <PersonalEesNimi>Tsvetkov</PersonalEesNimi> };
// <Telefon>+37258131202</Telefon>
// </Personal>`;
// };
export const getPatient = ({ export const getPatient = ({
idCode, idCode,

View File

@@ -15,11 +15,12 @@ export function toArray<T>(input?: T | T[] | null): T[] {
} }
export function toTitleCase(str?: string) { export function toTitleCase(str?: string) {
if (!str) return ''; return (
return str.replace( str
/\w\S*/g, ?.toLowerCase()
(text: string) => .replace(/[^-'\s]+/g, (match) =>
text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(), match.replace(/^./, (first) => first.toUpperCase()),
) ?? ""
); );
} }
@@ -40,8 +41,12 @@ export function sortByDate<T>(
export const bmiFromMetric = (kg: number, cm: number) => { export const bmiFromMetric = (kg: number, cm: number) => {
const m = cm / 100; const m = cm / 100;
const bmi = kg / (m * m); const m2 = m * m;
return bmi ? Math.round(bmi) : NaN; if (m2 === 0) {
return null;
}
const bmi = kg / m2;
return !Number.isNaN(bmi) ? Math.round(bmi) : null;
}; };
export function getBmiStatus( export function getBmiStatus(
@@ -58,7 +63,9 @@ export function getBmiStatus(
) || null; ) || null;
const bmi = bmiFromMetric(params.weight, params.height); const bmi = bmiFromMetric(params.weight, params.height);
if (!thresholdByAge || Number.isNaN(bmi)) return null; if (!thresholdByAge || bmi === null) {
return null;
}
if (bmi > thresholdByAge.obesity_min) return BmiCategory.OBESE; if (bmi > thresholdByAge.obesity_min) return BmiCategory.OBESE;
if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT; if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT;

View File

@@ -50,7 +50,7 @@ const config = {
}, },
experimental: { experimental: {
mdxRs: true, mdxRs: true,
reactCompiler: ENABLE_REACT_COMPILER, reactCompiler: false,
optimizePackageImports: [ optimizePackageImports: [
'recharts', 'recharts',
'lucide-react', 'lucide-react',

View File

@@ -69,6 +69,7 @@
"fast-xml-parser": "^5.2.5", "fast-xml-parser": "^5.2.5",
"isikukood": "3.1.7", "isikukood": "3.1.7",
"jsonwebtoken": "9.0.2", "jsonwebtoken": "9.0.2",
"libphonenumber-js": "^1.12.15",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.510.0", "lucide-react": "^0.510.0",
"next": "15.3.2", "next": "15.3.2",

View File

@@ -79,15 +79,9 @@ export function PersonalAccountDropdown({
}) { }) {
const { data: personalAccountData } = usePersonalAccountData(user.id); const { data: personalAccountData } = usePersonalAccountData(user.id);
const signedInAsLabel = useMemo(() => { const { name, last_name } = personalAccountData ?? {};
const email = user?.email ?? undefined; const firstNameLabel = toTitleCase(name) ?? '-';
const phone = user?.phone ?? undefined; const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
return email ?? phone;
}, [user]);
const displayName =
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
const hasTotpFactor = useMemo(() => { const hasTotpFactor = useMemo(() => {
const factors = user?.factors ?? []; const factors = user?.factors ?? [];
@@ -128,7 +122,7 @@ export function PersonalAccountDropdown({
<ProfileAvatar <ProfileAvatar
className={'rounded-md'} className={'rounded-md'}
fallbackClassName={'rounded-md border'} fallbackClassName={'rounded-md border'}
displayName={displayName ?? user?.email ?? ''} displayName={firstNameLabel}
pictureUrl={personalAccountData?.picture_url} pictureUrl={personalAccountData?.picture_url}
/> />
@@ -142,7 +136,7 @@ export function PersonalAccountDropdown({
data-test={'account-dropdown-display-name'} data-test={'account-dropdown-display-name'}
className={'truncate text-sm'} className={'truncate text-sm'}
> >
{toTitleCase(displayName)} {firstNameLabel}
</span> </span>
</div> </div>
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
</div> </div>
<div> <div>
<span className={'block truncate'}>{signedInAsLabel}</span> <span className={'block truncate'}>{fullNameLabel}</span>
</div> </div>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -48,6 +48,41 @@ class AccountsApi {
return data; return data;
} }
/**
* @name getPersonalAccountByUserId
* @description Get the personal account data for the given user ID.
* @param userId
*/
async getPersonalAccountByUserId(userId: string): Promise<AccountWithParams> {
const { data, error } = await this.client
.schema('medreport')
.from('accounts')
.select(
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
)
.eq('primary_owner_user_id', userId)
.eq('is_personal_account', true)
.single();
if (error) {
throw error;
}
const { personal_code, ...rest } = data;
return {
...rest,
personal_code: (() => {
if (!personal_code) {
return null;
}
if (personal_code.toLowerCase().startsWith('ee')) {
return personal_code.substring(2);
}
return personal_code;
})(),
};
}
/** /**
* @name getAccountWorkspace * @name getAccountWorkspace
* @description Get the account workspace data. * @description Get the account workspace data.

View File

@@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button';
* @see https://supabase.com/docs/guides/auth/social-login * @see https://supabase.com/docs/guides/auth/social-login
*/ */
const OAUTH_SCOPES: Partial<Record<Provider, string>> = { const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
azure: 'email', // azure: 'email',
keycloak: 'openid', // keycloak: 'openid',
// add your OAuth providers here // add your OAuth providers here
}; };
@@ -88,10 +88,12 @@ export const OauthProviders: React.FC<{
queryParams.set('invite_token', props.inviteToken); queryParams.set('invite_token', props.inviteToken);
} }
const redirectPath = [ // signicat/keycloak will not allow redirect-uri with changing query params
props.paths.callback, const INCLUDE_QUERY_PARAMS = false as boolean;
queryParams.toString(),
].join('?'); const redirectPath = INCLUDE_QUERY_PARAMS
? [props.paths.callback, queryParams.toString()].join('?')
: props.paths.callback;
const redirectTo = [origin, redirectPath].join(''); const redirectTo = [origin, redirectPath].join('');
const scopes = OAUTH_SCOPES[provider] ?? undefined; const scopes = OAUTH_SCOPES[provider] ?? undefined;
@@ -102,6 +104,7 @@ export const OauthProviders: React.FC<{
redirectTo, redirectTo,
queryParams: props.queryParams, queryParams: props.queryParams,
scopes, scopes,
// skipBrowserRedirect: false,
}, },
} satisfies SignInWithOAuthCredentials; } satisfies SignInWithOAuthCredentials;

View File

@@ -108,6 +108,9 @@ export function SignInMethodsContainer(props: {
callback: props.paths.callback, callback: props.paths.callback,
returnPath: props.paths.returnPath, returnPath: props.paths.returnPath,
}} }}
queryParams={{
prompt: 'login',
}}
/> />
</If> </If>
</> </>

View File

@@ -44,7 +44,7 @@ export function SignUpMethodsContainer(props: {
emailRedirectTo={props.paths.callback} emailRedirectTo={props.paths.callback}
defaultValues={defaultValues} defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox} displayTermsCheckbox={props.displayTermsCheckbox}
onSignUp={() => redirect(redirectUrl)} //onSignUp={() => redirect(redirectUrl)}
/> />
</If> </If>
@@ -79,6 +79,9 @@ export function SignUpMethodsContainer(props: {
callback: props.paths.callback, callback: props.paths.callback,
returnPath: props.paths.appHome, returnPath: props.paths.appHome,
}} }}
queryParams={{
prompt: 'login',
}}
/> />
</If> </If>
</> </>

View File

@@ -87,7 +87,10 @@ export async function getOrSetCart(countryCode: string) {
return cart; return cart;
} }
export async function updateCart({ id, ...data }: HttpTypes.StoreUpdateCart & { id?: string }) { export async function updateCart(
{ id, ...data }: HttpTypes.StoreUpdateCart & { id?: string },
{ onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} },
) {
const cartId = id || (await getCartId()); const cartId = id || (await getCartId());
if (!cartId) { if (!cartId) {
@@ -109,9 +112,13 @@ export async function updateCart({ id, ...data }: HttpTypes.StoreUpdateCart & {
const fulfillmentCacheTag = await getCacheTag("fulfillment"); const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag); revalidateTag(fulfillmentCacheTag);
onSuccess();
return cart; return cart;
}) })
.catch(medusaError); .catch((e) => {
onError();
return medusaError(e);
});
} }
export async function addToCart({ export async function addToCart({
@@ -259,7 +266,10 @@ export async function initiatePaymentSession(
.catch(medusaError); .catch(medusaError);
} }
export async function applyPromotions(codes: string[]) { export async function applyPromotions(
codes: string[],
{ onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} },
) {
const cartId = await getCartId(); const cartId = await getCartId();
if (!cartId) { if (!cartId) {
@@ -278,8 +288,13 @@ export async function applyPromotions(codes: string[]) {
const fulfillmentCacheTag = await getCacheTag("fulfillment"); const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag); revalidateTag(fulfillmentCacheTag);
onSuccess();
}) })
.catch(medusaError); .catch((e) => {
onError();
return medusaError(e);
});
} }
export async function applyGiftCard(code: string) { export async function applyGiftCard(code: string) {
@@ -427,7 +442,7 @@ export async function placeOrder(cartId?: string, options: { revalidateCacheTags
} else { } else {
throw new Error("Cart is not an order"); throw new Error("Cart is not an order");
} }
return retrieveOrder(cartRes.order.id); return retrieveOrder(cartRes.order.id);
} }

View File

@@ -4,7 +4,6 @@ import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error" import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache" import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import { import {
getAuthHeaders, getAuthHeaders,
getCacheOptions, getCacheOptions,
@@ -127,7 +126,7 @@ export async function login(_currentState: unknown, formData: FormData) {
} }
} }
export async function signout(countryCode: string) { export async function medusaLogout(countryCode = 'ee') {
await sdk.auth.logout() await sdk.auth.logout()
await removeAuthToken() await removeAuthToken()
@@ -139,8 +138,6 @@ export async function signout(countryCode: string) {
const cartCacheTag = await getCacheTag("carts") const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag) revalidateTag(cartCacheTag)
redirect(`/${countryCode}/account`)
} }
export async function transferCart() { export async function transferCart() {
@@ -260,62 +257,110 @@ export const updateCustomerAddress = async (
}) })
} }
export async function medusaLoginOrRegister(credentials: { async function medusaLogin(email: string, password: string) {
email: string const token = await sdk.auth.login("customer", "emailpass", { email, password });
password?: string await setAuthToken(token as string);
}) {
const { email, password } = credentials;
try { try {
const token = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(token as string);
await transferCart(); await transferCart();
} catch (e) {
console.error("Failed to transfer cart", e);
}
const customerCacheTag = await getCacheTag("customers"); const customer = await retrieveCustomer();
revalidateTag(customerCacheTag); if (!customer) {
throw new Error("Customer not found for active session");
}
const customer = await retrieveCustomer(); return customer.id;
if (!customer) { }
throw new Error("Customer not found");
async function medusaRegister({
email,
password,
name,
lastName,
}: {
email: string;
password: string;
name: string | undefined;
lastName: string | undefined;
}) {
console.info(`Creating new Medusa account for Keycloak user with email=${email}`);
const registerToken = await sdk.auth.register("customer", "emailpass", { email, password });
await setAuthToken(registerToken);
console.info(`Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`);
await sdk.store.customer.create(
{ email, first_name: name, last_name: lastName },
{},
{
...(await getAuthHeaders()),
});
}
export async function medusaLoginOrRegister(credentials: {
email: string
supabaseUserId?: string
name?: string,
lastName?: string,
} & ({ isDevPasswordLogin: true; password: string } | { isDevPasswordLogin?: false; password?: undefined })) {
const { email, supabaseUserId, name, lastName } = credentials;
const password = await (async () => {
if (credentials.isDevPasswordLogin) {
return credentials.password;
} }
return customer.id;
} catch (error) { return generateDeterministicPassword(email, supabaseUserId);
console.error("Failed to login customer, attempting to register", error); })();
try {
return await medusaLogin(email, password);
} catch (loginError) {
console.error("Failed to login customer, attempting to register", loginError);
try { try {
const registerToken = await sdk.auth.register("customer", "emailpass", { await medusaRegister({ email, password, name, lastName });
email: email, return await medusaLogin(email, password);
password: password,
})
await setAuthToken(registerToken as string);
const headers = {
...(await getAuthHeaders()),
};
await sdk.store.customer.create({ email }, {}, headers);
const loginToken = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(loginToken as string);
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
await transferCart();
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found");
}
return customer.id;
} catch (registerError) { } catch (registerError) {
console.error("Failed to create Medusa account for user with email=${email}", registerError);
throw medusaError(registerError); throw medusaError(registerError);
} }
} }
} }
/**
* Generate a deterministic password based on user identifier
* This ensures the same user always gets the same password for Medusa
*/
async function generateDeterministicPassword(email: string, userId?: string): Promise<string> {
// Use the user ID or email as the base for deterministic generation
const baseString = userId || email;
const secret = process.env.MEDUSA_PASSWORD_SECRET!;
// Create a deterministic password using HMAC
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(baseString);
// Import key for HMAC
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// Generate HMAC
const signature = await crypto.subtle.sign('HMAC', key, messageData);
// Convert to base64 and make it a valid password
const hashArray = Array.from(new Uint8Array(signature));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// Take first 24 characters and add some complexity
const basePassword = hashHex.substring(0, 24);
// Add some required complexity for Medusa (uppercase, lowercase, numbers, symbols)
return `Mk${basePassword}9!`;
}

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[] } queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[], category_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", "*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,+status",
...queryParams, ...queryParams,
}, },
headers, headers,

View File

@@ -10,7 +10,7 @@ import MapPin from "@modules/common/icons/map-pin"
import Package from "@modules/common/icons/package" import Package from "@modules/common/icons/package"
import LocalizedClientLink from "@modules/common/components/localized-client-link" import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { signout } from "@lib/data/customer" import { medusaLogout } from "@lib/data/customer"
const AccountNav = ({ const AccountNav = ({
customer, customer,
@@ -21,7 +21,7 @@ const AccountNav = ({
const { countryCode } = useParams() as { countryCode: string } const { countryCode } = useParams() as { countryCode: string }
const handleLogout = async () => { const handleLogout = async () => {
await signout(countryCode) await medusaLogout(countryCode)
} }
return ( return (

View File

@@ -13,7 +13,7 @@ export function InfoTooltip({
content, content,
icon, icon,
}: { }: {
content?: string | null; content?: JSX.Element | 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>{content}</TooltipContent> <TooltipContent className='sm:max-w-[400px]'>{content}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -32,7 +32,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: ['google'], oAuth: ['keycloak'],
}, },
} satisfies z.infer<typeof AuthConfigSchema>); } satisfies z.infer<typeof AuthConfigSchema>);

View File

@@ -4,6 +4,7 @@ import {
AuthError, AuthError,
type EmailOtpType, type EmailOtpType,
SupabaseClient, SupabaseClient,
User,
} from '@supabase/supabase-js'; } from '@supabase/supabase-js';
/** /**
@@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) {
* @description Service for handling auth callbacks in Supabase * @description Service for handling auth callbacks in Supabase
*/ */
class AuthCallbackService { class AuthCallbackService {
constructor(private readonly client: SupabaseClient) {} constructor(private readonly client: SupabaseClient) { }
/** /**
* @name verifyTokenHash * @name verifyTokenHash
@@ -128,89 +129,117 @@ class AuthCallbackService {
/** /**
* @name exchangeCodeForSession * @name exchangeCodeForSession
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page * @description Exchanges the auth code for a session and redirects the user to the next page or an error page
* @param request * @param authCode
* @param params
*/ */
async exchangeCodeForSession( async exchangeCodeForSession(authCode: string): Promise<{
request: Request, isSuccess: boolean;
params: { user: User;
joinTeamPath: string; } | ErrorURLParameters> {
redirectPath: string; let user: User;
errorPath?: string; try {
}, const { data, error } =
): Promise<{ await this.client.auth.exchangeCodeForSession(authCode);
nextPath: string;
}> {
const requestUrl = new URL(request.url);
const searchParams = requestUrl.searchParams;
const authCode = searchParams.get('code'); // if we have an error, we redirect to the error page
const error = searchParams.get('error'); if (error) {
const nextUrlPathFromParams = searchParams.get('next'); return getErrorURLParameters({
const inviteToken = searchParams.get('invite_token'); code: error.code,
const errorPath = params.errorPath ?? '/auth/callback/error'; error: error.message,
let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
// if we have an invite token, we redirect to the join team page
// instead of the default next url. This is because the user is trying
// to join a team and we want to make sure they are redirected to the
// correct page.
if (inviteToken) {
const emailParam = searchParams.get('email');
const urlParams = new URLSearchParams({
invite_token: inviteToken,
email: emailParam ?? '',
});
nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`;
}
if (authCode) {
try {
const { error } =
await this.client.auth.exchangeCodeForSession(authCode);
// if we have an error, we redirect to the error page
if (error) {
return onError({
code: error.code,
error: error.message,
path: errorPath,
});
}
} catch (error) {
console.error(
{
error,
name: `auth.callback`,
},
`An error occurred while exchanging code for session`,
);
const message = error instanceof Error ? error.message : error;
return onError({
code: (error as AuthError)?.code,
error: message as string,
path: errorPath,
}); });
} }
}
if (error) { // Handle Keycloak users - set up Medusa integration
return onError({ if (data?.user && this.isKeycloakUser(data.user)) {
error, await this.setupMedusaUserForKeycloak(data.user);
path: errorPath, }
user = data.user;
} catch (error) {
console.error(
{
error,
name: `auth.callback`,
},
`An error occurred while exchanging code for session`,
);
const message = error instanceof Error ? error.message : error;
return getErrorURLParameters({
code: (error as AuthError)?.code,
error: message as string,
}); });
} }
return { return {
nextPath: nextUrl, isSuccess: true,
user,
}; };
} }
/**
* Check if user is from Keycloak provider
*/
private isKeycloakUser(user: any): boolean {
return user?.app_metadata?.provider === 'keycloak' ||
user?.app_metadata?.providers?.includes('keycloak');
}
private async setupMedusaUserForKeycloak(user: any): Promise<void> {
if (!user.email) {
console.warn('Keycloak user has no email, skipping Medusa setup');
return;
}
try {
// Check if user already has medusa_account_id
const { data: accountData, error: fetchError } = await this.client
.schema('medreport')
.from('accounts')
.select('medusa_account_id, name, last_name')
.eq('primary_owner_user_id', user.id)
.eq('is_personal_account', true)
.single();
if (fetchError && fetchError.code !== 'PGRST116') {
console.error('Error fetching account data for Keycloak user:', fetchError);
return;
}
// If user already has Medusa account, we're done
if (accountData?.medusa_account_id) {
console.log('Keycloak user already has Medusa account:', accountData.medusa_account_id);
return;
}
const { medusaLoginOrRegister } = await import('../../features/medusa-storefront/src/lib/data/customer');
const medusaAccountId = await medusaLoginOrRegister({
email: user.email,
supabaseUserId: user.id,
name: accountData?.name ?? '-',
lastName: accountData?.last_name ?? '-',
});
// Update the account with the Medusa account ID
const { error: updateError } = await this.client
.schema('medreport')
.from('accounts')
.update({ medusa_account_id: medusaAccountId })
.eq('primary_owner_user_id', user.id)
.eq('is_personal_account', true);
if (updateError) {
console.error('Error updating account with Medusa ID:', updateError);
return;
}
console.log('Successfully set up Medusa account for Keycloak user:', medusaAccountId);
} catch (error) {
console.error('Error setting up Medusa account for Keycloak user:', error);
}
}
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) { private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) { if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
url.host = host as string; url.host = host as string;
@@ -231,15 +260,19 @@ class AuthCallbackService {
} }
} }
function onError({ interface ErrorURLParameters {
error: string;
code?: string;
searchParams: string;
}
export function getErrorURLParameters({
error, error,
path,
code, code,
}: { }: {
error: string; error: string;
path: string;
code?: string; code?: string;
}) { }): ErrorURLParameters {
const errorMessage = getAuthErrorMessage({ error, code }); const errorMessage = getAuthErrorMessage({ error, code });
console.error( console.error(
@@ -255,10 +288,10 @@ function onError({
code: code ?? '', code: code ?? '',
}); });
const nextPath = `${path}?${searchParams.toString()}`;
return { return {
nextPath, error: errorMessage,
code: code ?? '',
searchParams: searchParams.toString(),
}; };
} }

View File

@@ -10,5 +10,11 @@ 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,6 +20,11 @@ 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,6 +15,11 @@ 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

@@ -28,6 +28,7 @@ export function useSignInWithEmailPassword() {
const medusaAccountId = await medusaLoginOrRegister({ const medusaAccountId = await medusaLoginOrRegister({
email: credentials.email, email: credentials.email,
password: credentials.password, password: credentials.password,
isDevPasswordLogin: true,
}); });
await client await client
.schema('medreport').from('accounts') .schema('medreport').from('accounts')

View File

@@ -9,7 +9,13 @@ export function useSignInWithProvider() {
const mutationKey = ['auth', 'sign-in-with-provider']; const mutationKey = ['auth', 'sign-in-with-provider'];
const mutationFn = async (credentials: SignInWithOAuthCredentials) => { const mutationFn = async (credentials: SignInWithOAuthCredentials) => {
const response = await client.auth.signInWithOAuth(credentials); const response = await client.auth.signInWithOAuth({
...credentials,
options: {
...credentials.options,
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (response.error) { if (response.error) {
throw response.error.message; throw response.error.message;

View File

@@ -6,8 +6,23 @@ export function useSignOut() {
const client = useSupabase(); const client = useSupabase();
return useMutation({ return useMutation({
mutationFn: () => { mutationFn: async () => {
return client.auth.signOut(); try {
try {
const { medusaLogout } = await import('../../../features/medusa-storefront/src/lib/data/customer');
await medusaLogout();
} catch (medusaError) {
console.warn('Medusa logout failed or not available:', medusaError);
}
const { error } = await client.auth.signOut();
if (error) {
throw error;
}
} catch (error) {
console.error('Logout error:', error);
throw error;
}
}, },
}); });
} }

View File

@@ -43,6 +43,7 @@ export function useSignUpWithEmailAndPassword() {
const medusaAccountId = await medusaLoginOrRegister({ const medusaAccountId = await medusaLoginOrRegister({
email: credentials.email, email: credentials.email,
password: credentials.password, password: credentials.password,
isDevPasswordLogin: true,
}); });
await client await client
.schema('medreport').from('accounts') .schema('medreport').from('accounts')

57
pnpm-lock.yaml generated
View File

@@ -128,6 +128,9 @@ importers:
jsonwebtoken: jsonwebtoken:
specifier: 9.0.2 specifier: 9.0.2
version: 9.0.2 version: 9.0.2
libphonenumber-js:
specifier: ^1.12.15
version: 1.12.15
lodash: lodash:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
@@ -475,10 +478,10 @@ importers:
dependencies: dependencies:
'@keystatic/core': '@keystatic/core':
specifier: 0.5.47 specifier: 0.5.47
version: 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@keystatic/next': '@keystatic/next':
specifier: ^5.0.4 specifier: ^5.0.4
version: 5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@markdoc/markdoc': '@markdoc/markdoc':
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.4(@types/react@19.1.4)(react@19.1.0) version: 0.5.4(@types/react@19.1.4)(react@19.1.0)
@@ -1269,7 +1272,7 @@ importers:
dependencies: dependencies:
'@sentry/nextjs': '@sentry/nextjs':
specifier: ^9.19.0 specifier: ^9.19.0
version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3) version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
import-in-the-middle: import-in-the-middle:
specifier: 1.13.2 specifier: 1.13.2
version: 1.13.2 version: 1.13.2
@@ -8174,6 +8177,9 @@ packages:
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
libphonenumber-js@1.12.15:
resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==}
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -11455,7 +11461,7 @@ snapshots:
'@juggle/resize-observer@3.4.0': {} '@juggle/resize-observer@3.4.0': {}
'@keystar/ui@0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': '@keystar/ui@0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies: dependencies:
'@babel/runtime': 7.27.6 '@babel/runtime': 7.27.6
'@emotion/css': 11.13.5 '@emotion/css': 11.13.5
@@ -11548,18 +11554,18 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': '@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies: dependencies:
'@babel/runtime': 7.27.6 '@babel/runtime': 7.27.6
'@braintree/sanitize-url': 6.0.4 '@braintree/sanitize-url': 6.0.4
'@emotion/weak-memoize': 0.3.1 '@emotion/weak-memoize': 0.3.1
'@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@internationalized/string': 3.2.7 '@internationalized/string': 3.2.7
'@keystar/ui': 0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@keystar/ui': 0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0) '@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0)
'@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -11630,13 +11636,13 @@ snapshots:
- next - next
- supports-color - supports-color
'@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': '@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies: dependencies:
'@babel/runtime': 7.27.6 '@babel/runtime': 7.27.6
'@keystatic/core': 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@keystatic/core': 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@types/react': 19.1.4 '@types/react': 19.1.4
chokidar: 3.6.0 chokidar: 3.6.0
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
server-only: 0.0.1 server-only: 0.0.1
@@ -17230,7 +17236,7 @@ snapshots:
'@sentry/core@9.46.0': {} '@sentry/core@9.46.0': {}
'@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)': '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.34.0 '@opentelemetry/semantic-conventions': 1.34.0
@@ -17243,7 +17249,7 @@ snapshots:
'@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) '@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))
'@sentry/webpack-plugin': 3.5.0(webpack@5.101.3) '@sentry/webpack-plugin': 3.5.0(webpack@5.101.3)
chalk: 3.0.0 chalk: 3.0.0
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
resolve: 1.22.8 resolve: 1.22.8
rollup: 4.35.0 rollup: 4.35.0
stacktrace-parser: 0.1.11 stacktrace-parser: 0.1.11
@@ -20630,6 +20636,8 @@ snapshots:
dependencies: dependencies:
isomorphic.js: 0.2.5 isomorphic.js: 0.2.5
libphonenumber-js@1.12.15: {}
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
optional: true optional: true
@@ -21265,31 +21273,6 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 15.5.2
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001723
postcss: 8.4.31
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@19.1.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.2
'@next/swc-darwin-x64': 15.5.2
'@next/swc-linux-arm64-gnu': 15.5.2
'@next/swc-linux-arm64-musl': 15.5.2
'@next/swc-linux-x64-gnu': 15.5.2
'@next/swc-linux-x64-musl': 15.5.2
'@next/swc-win32-arm64-msvc': 15.5.2
'@next/swc-win32-x64-msvc': 15.5.2
'@opentelemetry/api': 1.9.0
babel-plugin-react-compiler: 19.1.0-rc.2
sharp: 0.34.3
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
no-case@3.0.4: no-case@3.0.4:
dependencies: dependencies:
lower-case: 2.0.2 lower-case: 2.0.2

View File

@@ -5,6 +5,7 @@
"emptyCartMessageDescription": "Add items to your cart to continue.", "emptyCartMessageDescription": "Add items to your cart to continue.",
"subtotal": "Subtotal", "subtotal": "Subtotal",
"total": "Total", "total": "Total",
"promotionsTotal": "Promotions total",
"table": { "table": {
"item": "Item", "item": "Item",
"quantity": "Quantity", "quantity": "Quantity",
@@ -24,10 +25,13 @@
"timeoutAction": "Continue" "timeoutAction": "Continue"
}, },
"discountCode": { "discountCode": {
"title": "Gift card or promotion code",
"label": "Add Promotion Code(s)", "label": "Add Promotion Code(s)",
"apply": "Apply", "apply": "Apply",
"subtitle": "If you wish, you can add a promotion code", "subtitle": "If you wish, you can add a promotion code",
"placeholder": "Enter promotion code" "placeholder": "Enter promotion code",
"remove": "Remove promotion code",
"appliedCodes": "Promotion(s) applied:"
}, },
"items": { "items": {
"synlabAnalyses": { "synlabAnalyses": {

View File

@@ -128,6 +128,9 @@
"amount": "Amount", "amount": "Amount",
"selectDate": "Select date" "selectDate": "Select date"
}, },
"formFieldError": {
"invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)"
},
"wallet": { "wallet": {
"balance": "Your MedReport account balance", "balance": "Your MedReport account balance",
"expiredAt": "Valid until {{expiredAt}}" "expiredAt": "Valid until {{expiredAt}}"

View File

@@ -4,6 +4,7 @@
"emptyCartMessage": "Sinu ostukorv on tühi", "emptyCartMessage": "Sinu ostukorv on tühi",
"emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.", "emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.",
"subtotal": "Vahesumma", "subtotal": "Vahesumma",
"promotionsTotal": "Soodustuse summa",
"total": "Summa", "total": "Summa",
"table": { "table": {
"item": "Toode", "item": "Toode",
@@ -28,7 +29,13 @@
"label": "Lisa promo kood", "label": "Lisa promo kood",
"apply": "Rakenda", "apply": "Rakenda",
"subtitle": "Kui soovid, võid lisada promo koodi", "subtitle": "Kui soovid, võid lisada promo koodi",
"placeholder": "Sisesta promo kood" "placeholder": "Sisesta promo kood",
"remove": "Eemalda promo kood",
"appliedCodes": "Rakendatud sooduskoodid:",
"removeError": "Sooduskoodi eemaldamine ebaõnnestus",
"removeSuccess": "Sooduskood eemaldatud",
"addError": "Sooduskoodi rakendamine ebaõnnestus",
"addSuccess": "Sooduskood rakendatud"
}, },
"items": { "items": {
"synlabAnalyses": { "synlabAnalyses": {

View File

@@ -128,6 +128,9 @@
"amount": "Summa", "amount": "Summa",
"selectDate": "Vali kuupäev" "selectDate": "Vali kuupäev"
}, },
"formFieldError": {
"invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)"
},
"wallet": { "wallet": {
"balance": "Sinu MedReporti konto saldo", "balance": "Sinu MedReporti konto saldo",
"expiredAt": "Kehtiv kuni {{expiredAt}}" "expiredAt": "Kehtiv kuni {{expiredAt}}"

View File

@@ -0,0 +1,94 @@
-- Update the user creation trigger to properly handle Keycloak user metadata
CREATE OR REPLACE FUNCTION kit.setup_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $$
DECLARE
user_name text;
picture_url text;
personal_code text;
full_name text;
given_name text;
family_name text;
preferred_username text;
BEGIN
-- Extract data from Keycloak user metadata
-- Check raw_user_meta_data first (this is where Keycloak data is stored)
IF new.raw_user_meta_data IS NOT NULL THEN
-- Try full_name first, then name field
full_name := new.raw_user_meta_data ->> 'full_name';
IF full_name IS NULL THEN
full_name := new.raw_user_meta_data ->> 'name';
END IF;
-- Extract individual name components
given_name := new.raw_user_meta_data -> 'custom_claims' ->> 'given_name';
family_name := new.raw_user_meta_data -> 'custom_claims' ->> 'family_name';
preferred_username := new.raw_user_meta_data -> 'custom_claims' ->> 'preferred_username';
-- Use given_name (first name) for the name field
IF given_name IS NOT NULL THEN
user_name := given_name;
ELSIF full_name IS NOT NULL THEN
user_name := full_name;
ELSIF preferred_username IS NOT NULL THEN
user_name := preferred_username;
END IF;
-- Extract personal code from preferred_username (Keycloak provides Estonian personal codes here)
IF preferred_username IS NOT NULL THEN
personal_code := preferred_username;
END IF;
-- Also try personalCode field as fallback
IF personal_code IS NULL THEN
personal_code := new.raw_user_meta_data ->> 'personalCode';
END IF;
END IF;
-- Fall back to email if no name found
IF user_name IS NULL AND new.email IS NOT NULL THEN
user_name := split_part(new.email, '@', 1);
END IF;
-- Default empty string if still no name
IF user_name IS NULL THEN
user_name := '';
END IF;
-- Extract picture URL
IF new.raw_user_meta_data ->> 'avatar_url' IS NOT NULL THEN
picture_url := new.raw_user_meta_data ->> 'avatar_url';
ELSE
picture_url := null;
END IF;
-- Insert into medreport.accounts
INSERT INTO medreport.accounts (
id,
primary_owner_user_id,
name,
last_name,
is_personal_account,
picture_url,
email,
personal_code,
application_role
)
VALUES (
new.id,
new.id,
user_name,
family_name,
true,
picture_url,
NULL, -- Keycloak email !== customer personal email, they will set this later
personal_code,
'user' -- Default role for new users
);
RETURN new;
END;
$$;