Compare commits
48 Commits
21375cf55f
...
keycloak-u
| Author | SHA1 | Date | |
|---|---|---|---|
| aa441d4055 | |||
| c882a24415 | |||
| 077aaee181 | |||
| 57a998d215 | |||
| f01829de96 | |||
| 7815a1c011 | |||
| 96eea95fb9 | |||
| 6495d1c4a3 | |||
| a9612ad992 | |||
| ab2176bc69 | |||
| a89d8d3153 | |||
| 1b29cb222b | |||
| dfcfdb8f97 | |||
| d87d08aaea | |||
| c83694222d | |||
| c08fe26b36 | |||
| d3202a2cb2 | |||
| 2435e6f113 | |||
| 54856b0e45 | |||
| 95e72bb3f8 | |||
| 3d268b6061 | |||
| 5c6280ec42 | |||
| 0de9dcf7e3 | |||
| f3a6fb627c | |||
| c1746c6c20 | |||
| c6f56f6e11 | |||
| a6b246cdf3 | |||
| a705dea9cf | |||
| 8485d2e9a3 | |||
| c356f69656 | |||
| cacd23be40 | |||
| f8765dce49 | |||
| 42bebb6d93 | |||
| 354a0c04ee | |||
| 72bb9a33ef | |||
| 771c28f8ef | |||
|
|
e9497c3d52 | ||
| 70188f297f | |||
| e0940a1600 | |||
| 65eb6c780d | |||
| 6e9cde6b95 | |||
| 3a062eaa9c | |||
| c07acb85a2 | |||
| 5c8f8b73d7 | |||
| 5b52da0a62 | |||
| 1de564b917 | |||
| a2c080914a | |||
|
|
94dd00b9ca |
5
.env
5
.env
@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
|
||||
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
||||
|
||||
# AUTH
|
||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||
NEXT_PUBLIC_AUTH_PASSWORD=false
|
||||
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||
|
||||
@@ -65,3 +65,6 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
|
||||
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
||||
|
||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
||||
|
||||
# Configure Medusa password secret for Keycloak users
|
||||
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
# SITE
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||
|
||||
# SUPABASE DEVELOPMENT
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
||||
|
||||
# SUPABASE
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
|
||||
# NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
|
||||
# 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
|
||||
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||
# # MONTONIO
|
||||
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||
|
||||
10
.env.staging
10
.env.staging
@@ -6,10 +6,10 @@
|
||||
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
||||
|
||||
# SUPABASE
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://kaldvociniytdbbcxvqk.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImthbGR2b2Npbml5dGRiYmN4dnFrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYzNjQ5OTYsImV4cCI6MjA3MTk0MDk5Nn0.eixihH2KGkJZolY9FiQDicJOo2kxvXrSe6gGUCrkLo0
|
||||
# NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
|
||||
# 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
|
||||
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||
# # MONTONIO
|
||||
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -22,12 +22,12 @@ COPY . .
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN set -a \
|
||||
&& . .env \
|
||||
&& . .env.production \
|
||||
&& . .env.staging \
|
||||
&& set +a \
|
||||
&& node check-env.js \
|
||||
&& pnpm build
|
||||
&& . .env \
|
||||
&& . .env.production \
|
||||
&& . .env.staging \
|
||||
&& set +a \
|
||||
&& node check-env.js \
|
||||
&& pnpm build
|
||||
|
||||
|
||||
# --- Stage 2: Runtime ---
|
||||
@@ -41,13 +41,13 @@ COPY --from=builder /app ./
|
||||
RUN cp ".env.${APP_ENV}" .env.local
|
||||
|
||||
RUN npm install -g pnpm@9 \
|
||||
&& pnpm install --prod --frozen-lockfile
|
||||
&& pnpm install --prod --frozen-lockfile
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# 🔍 Optional: Log key envs for debug
|
||||
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
|
||||
|
||||
@@ -13,10 +13,7 @@ import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { featureFlagsConfig } from '@kit/shared/config';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
||||
|
||||
const ModeToggle = dynamic(() =>
|
||||
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||
@@ -75,11 +72,13 @@ function AuthButtons() {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Trans i18nKey={'auth:signUp'} />
|
||||
</Link>
|
||||
</Button>
|
||||
{authConfig.providers.password && (
|
||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Trans i18nKey={'auth:signUp'} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
|
||||
import { CtaButton, Hero } from '@kit/ui/marketing';
|
||||
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
|
||||
return (
|
||||
<div className={'flex space-x-4'}>
|
||||
<CtaButton>
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<span className={'flex items-center space-x-0.5'}>
|
||||
<span>
|
||||
<Trans i18nKey={'common:getStarted'} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import loadEnv from "../handler/load-env";
|
||||
import validateApiKey from "../handler/validate-api-key";
|
||||
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
|
||||
import { getOrderedAnalysisIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
|
||||
import { retrieveOrder } from "@lib/data/orders";
|
||||
import { getMedipostDispatchTries } from "~/lib/services/audit.service";
|
||||
|
||||
@@ -25,7 +25,7 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
try {
|
||||
const medusaOrder = await retrieveOrder(medusaOrderId);
|
||||
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
|
||||
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
||||
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
|
||||
console.info("Successfully sent order to medipost");
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getAnalysisOrdersAdmin } from "~/lib/services/order.service";
|
||||
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
||||
import { retrieveOrder } from "@lib/data";
|
||||
import { getAccountAdmin } from "~/lib/services/account.service";
|
||||
import { getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
|
||||
import { getOrderedAnalysisIds } from "~/lib/services/medipost.service";
|
||||
import loadEnv from "../handler/load-env";
|
||||
import validateApiKey from "../handler/validate-api-key";
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
|
||||
const medusaOrder = await retrieveOrder(medusaOrderId)
|
||||
|
||||
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
|
||||
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder });
|
||||
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
|
||||
|
||||
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
|
||||
const idsToSend = orderedAnalysisElementsIds;
|
||||
@@ -35,8 +35,8 @@ export async function POST(request: NextRequest) {
|
||||
lastName: account.last_name ?? '',
|
||||
phone: account.phone ?? '',
|
||||
},
|
||||
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
|
||||
orderedAnalysesIds: [],
|
||||
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||
orderId: medusaOrderId,
|
||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getOrder } from "~/lib/services/order.service";
|
||||
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
||||
import { retrieveOrder } from "@lib/data";
|
||||
import { getAccountAdmin } from "~/lib/services/account.service";
|
||||
import { createMedipostActionLog, getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
|
||||
import { createMedipostActionLog, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// const isDev = process.env.NODE_ENV === 'development';
|
||||
@@ -11,16 +11,15 @@ export async function POST(request: Request) {
|
||||
// return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 });
|
||||
// }
|
||||
|
||||
const { medusaOrderId, maxItems = null } = await request.json();
|
||||
const { medusaOrderId } = await request.json();
|
||||
|
||||
const medusaOrder = await retrieveOrder(medusaOrderId)
|
||||
const medreportOrder = await getOrder({ medusaOrderId });
|
||||
|
||||
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
|
||||
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder });
|
||||
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
|
||||
|
||||
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} (${maxItems ?? 'all'}) ordered analysis elements`);
|
||||
const idsToSend = typeof maxItems === 'number' ? orderedAnalysisElementsIds.slice(0, maxItems) : orderedAnalysisElementsIds;
|
||||
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
|
||||
const messageXml = await composeOrderTestResponseXML({
|
||||
person: {
|
||||
idCode: account.personal_code!,
|
||||
@@ -28,8 +27,8 @@ export async function POST(request: Request) {
|
||||
lastName: account.last_name ?? '',
|
||||
phone: account.phone ?? '',
|
||||
},
|
||||
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
|
||||
orderedAnalysesIds: [],
|
||||
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||
orderId: medusaOrderId,
|
||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||
});
|
||||
|
||||
@@ -1,19 +1,62 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
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 { 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) {
|
||||
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 oauthResult = await service.exchangeCodeForSession(authCode);
|
||||
if (!("isSuccess" in oauthResult)) {
|
||||
return redirectOnError(oauthResult.searchParams);
|
||||
}
|
||||
|
||||
const { nextPath } = await service.exchangeCodeForSession(request, {
|
||||
joinTeamPath: pathsConfig.app.joinTeam,
|
||||
redirectPath: pathsConfig.app.home,
|
||||
});
|
||||
const api = createAccountsApi(getSupabaseServerClient());
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||
|
||||
|
||||
54
app/auth/sign-in/components/PasswordOption.tsx
Normal file
54
app/auth/sign-in/components/PasswordOption.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
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';
|
||||
import { pathsConfig, authConfig } from '@kit/shared/config';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
|
||||
import PasswordOption from './components/PasswordOption';
|
||||
|
||||
interface SignInPageProps {
|
||||
searchParams: Promise<{
|
||||
@@ -26,47 +21,14 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const { invite_token: inviteToken, next = pathsConfig.app.home } =
|
||||
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
|
||||
await searchParams;
|
||||
|
||||
const signUpPath =
|
||||
pathsConfig.auth.signUp +
|
||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||
if (authConfig.providers.password) {
|
||||
return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
|
||||
}
|
||||
|
||||
const paths = {
|
||||
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>
|
||||
</>
|
||||
);
|
||||
return <SignInPageClientRedirect />;
|
||||
}
|
||||
|
||||
export default withI18n(SignInPage);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
||||
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||
@@ -37,6 +38,10 @@ async function SignUpPage({ searchParams }: Props) {
|
||||
pathsConfig.auth.signIn +
|
||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||
|
||||
if (!authConfig.providers.password) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { User } from '@supabase/supabase-js';
|
||||
|
||||
import { ExternalLink } from '@/public/assets/external-link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -23,31 +21,52 @@ import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
|
||||
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({
|
||||
resolver: zodResolver(UpdateAccountSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
personalCode: '',
|
||||
email: user.email,
|
||||
phone: '',
|
||||
city: '',
|
||||
weight: 0,
|
||||
height: 0,
|
||||
userConsent: false,
|
||||
},
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
||||
onSubmit={form.handleSubmit(onUpdateAccount)}
|
||||
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
|
||||
>
|
||||
<FormField
|
||||
name="firstName"
|
||||
disabled={hasFirstName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
@@ -63,6 +82,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
||||
|
||||
<FormField
|
||||
name="lastName"
|
||||
disabled={hasLastName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
@@ -78,6 +98,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
||||
|
||||
<FormField
|
||||
name="personalCode"
|
||||
disabled={hasPersonalCode}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
@@ -93,13 +114,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
||||
|
||||
<FormField
|
||||
name="email"
|
||||
disabled={hasEmail}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:formField:email'} />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled />
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import parsePhoneNumber from 'libphonenumber-js/min';
|
||||
|
||||
export const UpdateAccountSchema = z.object({
|
||||
firstName: z
|
||||
@@ -23,7 +24,20 @@ export const UpdateAccountSchema = z.object({
|
||||
.string({
|
||||
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(),
|
||||
weight: z
|
||||
.number({
|
||||
|
||||
@@ -28,11 +28,15 @@ export const onUpdateAccount = enhanceAction(
|
||||
console.warn('On update account error: ', err);
|
||||
}
|
||||
|
||||
await updateCustomer({
|
||||
first_name: params.firstName,
|
||||
last_name: params.lastName,
|
||||
phone: params.phone,
|
||||
});
|
||||
try {
|
||||
await updateCustomer({
|
||||
first_name: params.firstName,
|
||||
last_name: params.lastName,
|
||||
phone: params.phone,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update Medusa customer", e);
|
||||
}
|
||||
|
||||
const hasUnseenMembershipConfirmation =
|
||||
await api.hasUnseenMembershipConfirmation();
|
||||
|
||||
@@ -11,18 +11,39 @@ import { Trans } from '@kit/ui/trans';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
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() {
|
||||
const client = getSupabaseServerClient();
|
||||
const account = await loadCurrentUserAccount();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await client.auth.getUser();
|
||||
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
|
||||
|
||||
if (!user) {
|
||||
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 (
|
||||
<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">
|
||||
@@ -34,7 +55,7 @@ async function UpdateAccount() {
|
||||
<p className="text-muted-foreground pt-1 text-sm">
|
||||
<Trans i18nKey={'account:updateAccount:description'} />
|
||||
</p>
|
||||
<UpdateAccountForm user={user} />
|
||||
<UpdateAccountForm defaultValues={defaultValues} />
|
||||
</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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { listProductTypes } from "@lib/data/products";
|
||||
import { placeOrder, retrieveCart } from "@lib/data/cart";
|
||||
import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
|
||||
import { createOrder } from '~/lib/services/order.service';
|
||||
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
|
||||
import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
|
||||
import { createNotificationsApi } from '@kit/notifications/api';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { AccountWithParams } from '@kit/accounts/api';
|
||||
@@ -114,7 +114,7 @@ export async function processMontonioCallback(orderToken: string) {
|
||||
|
||||
|
||||
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
|
||||
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
|
||||
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
||||
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
|
||||
|
||||
const { productTypes } = await listProductTypes();
|
||||
|
||||
@@ -27,7 +27,7 @@ async function UserHomePage() {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const account = await loadCurrentUserAccount();
|
||||
const api = await createAccountsApi(client);
|
||||
const api = createAccountsApi(client);
|
||||
const bmiThresholds = await api.fetchBmiThresholds();
|
||||
|
||||
if (!account) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Badge, Heading, Text } from "@medusajs/ui"
|
||||
import { Badge, Text } from "@medusajs/ui"
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import React, { useActionState } from "react";
|
||||
|
||||
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
|
||||
@@ -31,11 +32,19 @@ export default function DiscountCode({ cart }: {
|
||||
|
||||
const removePromotionCode = async (code: string) => {
|
||||
const validPromotions = promotions.filter(
|
||||
(promotion) => promotion.code !== code
|
||||
(promotion) => promotion.code !== code,
|
||||
)
|
||||
|
||||
await applyPromotions(
|
||||
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!)
|
||||
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('cart:discountCode.removeSuccess'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('cart:discountCode.removeError'));
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,7 +54,14 @@ export default function DiscountCode({ cart }: {
|
||||
.map((p) => p.code!)
|
||||
codes.push(code.toString())
|
||||
|
||||
await applyPromotions(codes)
|
||||
await applyPromotions(codes, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('cart:discountCode.addSuccess'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('cart:discountCode.addError'));
|
||||
},
|
||||
});
|
||||
|
||||
form.reset()
|
||||
}
|
||||
@@ -64,7 +80,7 @@ export default function DiscountCode({ cart }: {
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
|
||||
className="w-full mb-2 flex gap-x-2"
|
||||
className="w-full mb-2 flex gap-x-2 sm:flex-row flex-col gap-y-2"
|
||||
>
|
||||
<FormField
|
||||
name={'code'}
|
||||
@@ -87,16 +103,12 @@ export default function DiscountCode({ cart }: {
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans i18nKey={'cart:discountCode.subtitle'} />
|
||||
</p>
|
||||
|
||||
{promotions.length > 0 && (
|
||||
<div className="w-full flex items-center">
|
||||
<div className="flex flex-col w-full">
|
||||
<Heading className="txt-medium mb-2">
|
||||
Promotion(s) applied:
|
||||
</Heading>
|
||||
{promotions.length > 0 ? (
|
||||
<div className="w-full flex items-center mt-4">
|
||||
<div className="flex flex-col w-full gap-y-2">
|
||||
<p>
|
||||
<Trans i18nKey={'cart:discountCode.appliedCodes'} />
|
||||
</p>
|
||||
|
||||
{promotions.map((promotion) => {
|
||||
return (
|
||||
@@ -110,6 +122,7 @@ export default function DiscountCode({ cart }: {
|
||||
<Badge
|
||||
color={promotion.is_automatic ? "green" : "grey"}
|
||||
size="small"
|
||||
className="px-4"
|
||||
>
|
||||
{promotion.code}
|
||||
</Badge>{" "}
|
||||
@@ -151,7 +164,7 @@ export default function DiscountCode({ cart }: {
|
||||
>
|
||||
<Trash size={14} />
|
||||
<span className="sr-only">
|
||||
Remove discount code from order
|
||||
<Trans i18nKey={'cart:discountCode.remove'} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -160,6 +173,10 @@ export default function DiscountCode({ cart }: {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans i18nKey={'cart:discountCode.subtitle'} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
|
||||
import AnalysisLocation from "./analysis-location";
|
||||
|
||||
const IS_DISCOUNT_SHOWN = false as boolean;
|
||||
const IS_DISCOUNT_SHOWN = true as boolean;
|
||||
|
||||
export default function Cart({
|
||||
cart,
|
||||
@@ -69,7 +69,7 @@ export default function Cart({
|
||||
|
||||
const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0;
|
||||
const isLocationsShown = synlabAnalyses.length > 0;
|
||||
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
|
||||
<div className="flex flex-col bg-white gap-y-6">
|
||||
@@ -77,28 +77,62 @@ export default function Cart({
|
||||
<CartItems cart={cart} items={ttoServiceItems} productColumnLabelKey="cart:items.ttoServices.productColumnLabel" />
|
||||
</div>
|
||||
{hasCartItems && (
|
||||
<div className="flex justify-end gap-x-4 px-6 py-4">
|
||||
<div className="mr-[36px]">
|
||||
<p className="ml-0 font-bold text-sm">
|
||||
<Trans i18nKey="cart:total" />
|
||||
</p>
|
||||
<>
|
||||
<div className="flex justify-end gap-x-4 px-6 pt-4">
|
||||
<div className="mr-[36px]">
|
||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
||||
<Trans i18nKey="cart:subtotal" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="mr-[116px]">
|
||||
<p className="text-sm">
|
||||
{formatCurrency({
|
||||
value: cart.subtotal,
|
||||
currencyCode: cart.currency_code,
|
||||
locale: language,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-[116px]">
|
||||
<p className="text-sm">
|
||||
{formatCurrency({
|
||||
value: cart.total,
|
||||
currencyCode: cart.currency_code,
|
||||
locale: language,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex justify-end gap-x-4 px-6 py-2">
|
||||
<div className="mr-[36px]">
|
||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
||||
<Trans i18nKey="cart:promotionsTotal" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="mr-[116px]">
|
||||
<p className="text-sm">
|
||||
{formatCurrency({
|
||||
value: cart.discount_total,
|
||||
currencyCode: cart.currency_code,
|
||||
locale: language,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-x-4 px-6">
|
||||
<div className="mr-[36px]">
|
||||
<p className="ml-0 font-bold text-sm">
|
||||
<Trans i18nKey="cart:total" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="mr-[116px]">
|
||||
<p className="text-sm">
|
||||
{formatCurrency({
|
||||
value: cart.total,
|
||||
currencyCode: cart.currency_code,
|
||||
locale: language,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-y-6 py-8">
|
||||
<div className="flex sm:flex-row flex-col gap-y-6 py-8 gap-x-4">
|
||||
{IS_DISCOUNT_SHOWN && (
|
||||
<Card
|
||||
className="flex flex-col justify-between w-1/2"
|
||||
className="flex flex-col justify-between w-full sm:w-1/2"
|
||||
>
|
||||
<CardHeader className="pb-4">
|
||||
<h5>
|
||||
@@ -113,7 +147,7 @@ export default function Cart({
|
||||
|
||||
{isLocationsShown && (
|
||||
<Card
|
||||
className="flex flex-col justify-between w-1/2"
|
||||
className="flex flex-col justify-between w-full sm:w-1/2"
|
||||
>
|
||||
<CardHeader className="pb-4">
|
||||
<h5>
|
||||
|
||||
128
app/home/(user)/_components/dashboard-recommendations.tsx
Normal file
128
app/home/(user)/_components/dashboard-recommendations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,20 +9,17 @@ import {
|
||||
Activity,
|
||||
ChevronRight,
|
||||
Clock9,
|
||||
Droplets,
|
||||
Pill,
|
||||
Scale,
|
||||
TrendingUp,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { getPersonParameters } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
@@ -38,6 +35,7 @@ import {
|
||||
getBmiBackgroundColor,
|
||||
getBmiStatus,
|
||||
} from '~/lib/utils';
|
||||
import DashboardRecommendations from './dashboard-recommendations';
|
||||
|
||||
const getCardVariant = (isSuccess: boolean | null): CardProps['variant'] => {
|
||||
if (isSuccess === null) return 'default';
|
||||
@@ -86,7 +84,7 @@ const cards = ({
|
||||
},
|
||||
{
|
||||
title: 'dashboard:bmi',
|
||||
description: bmiFromMetric(weight || 0, height || 0).toString(),
|
||||
description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
|
||||
icon: <TrendingUp />,
|
||||
iconBg: getBmiBackgroundColor(bmiStatus),
|
||||
},
|
||||
@@ -135,37 +133,7 @@ const cards = ({
|
||||
},
|
||||
];
|
||||
|
||||
const dummyRecommendations = [
|
||||
{
|
||||
icon: <BlendingModeIcon className="size-4" />,
|
||||
color: 'bg-cyan/10 text-cyan',
|
||||
title: 'Kolesterooli kontroll',
|
||||
description: 'HDL-kolestrool',
|
||||
tooltipContent: 'Selgitus',
|
||||
price: '20,00 €',
|
||||
buttonText: 'Telli',
|
||||
href: '/home/booking',
|
||||
},
|
||||
{
|
||||
icon: <BlendingModeIcon className="size-4" />,
|
||||
color: 'bg-primary/10 text-primary',
|
||||
title: 'Kolesterooli kontroll',
|
||||
tooltipContent: 'Selgitus',
|
||||
description: 'LDL-Kolesterool',
|
||||
buttonText: 'Broneeri',
|
||||
href: '/home/booking',
|
||||
},
|
||||
{
|
||||
icon: <Droplets />,
|
||||
color: 'bg-destructive/10 text-destructive',
|
||||
title: 'Vererõhu kontroll',
|
||||
tooltipContent: 'Selgitus',
|
||||
description: 'Score-Risk 2',
|
||||
price: '20,00 €',
|
||||
buttonText: 'Telli',
|
||||
href: '/home/booking',
|
||||
},
|
||||
];
|
||||
const IS_SHOWN_RECOMMENDATIONS = false as boolean;
|
||||
|
||||
export default function Dashboard({
|
||||
account,
|
||||
@@ -232,79 +200,7 @@ export default function Dashboard({
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="items-start">
|
||||
<h4>
|
||||
<Trans i18nKey="dashboard:recommendedForYou" />
|
||||
</h4>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{dummyRecommendations.map(
|
||||
(
|
||||
{
|
||||
icon,
|
||||
color,
|
||||
title,
|
||||
description,
|
||||
tooltipContent,
|
||||
price,
|
||||
buttonText,
|
||||
href,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
className="flex w-full justify-between gap-3 overflow-scroll"
|
||||
key={index}
|
||||
>
|
||||
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
|
||||
color,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-fit">
|
||||
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
|
||||
{title}
|
||||
<InfoTooltip content={tooltipContent} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-36 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>
|
||||
{IS_SHOWN_RECOMMENDATIONS && <DashboardRecommendations />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -15,12 +16,14 @@ import { handleAddToCart } from '~/lib/services/medusaCart.service';
|
||||
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { formatCurrency } from '@/packages/shared/src/utils';
|
||||
|
||||
export type OrderAnalysisCard = Pick<
|
||||
StoreProduct, 'title' | 'description' | 'subtitle'
|
||||
> & {
|
||||
isAvailable: boolean;
|
||||
variant: { id: string };
|
||||
price: number | null;
|
||||
};
|
||||
|
||||
export default function OrderAnalysesCards({
|
||||
@@ -30,23 +33,26 @@ export default function OrderAnalysesCards({
|
||||
analyses: OrderAnalysisCard[];
|
||||
countryCode: string;
|
||||
}) {
|
||||
const [isAddingToCart, setIsAddingToCart] = useState(false);
|
||||
|
||||
const { i18n: { language } } = useTranslation()
|
||||
|
||||
const [variantAddingToCart, setVariantAddingToCart] = useState<string | null>(null);
|
||||
const handleSelect = async (variantId: string) => {
|
||||
if (isAddingToCart) {
|
||||
if (variantAddingToCart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsAddingToCart(true);
|
||||
setVariantAddingToCart(variantId);
|
||||
try {
|
||||
await handleAddToCart({
|
||||
selectedVariant: { id: variantId },
|
||||
countryCode,
|
||||
});
|
||||
toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
|
||||
setIsAddingToCart(false);
|
||||
setVariantAddingToCart(null);
|
||||
} catch (e) {
|
||||
toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
|
||||
setIsAddingToCart(false);
|
||||
setVariantAddingToCart(null);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +65,15 @@ export default function OrderAnalysesCards({
|
||||
description,
|
||||
subtitle,
|
||||
isAvailable,
|
||||
price,
|
||||
}) => {
|
||||
const formattedPrice = typeof price === 'number'
|
||||
? formatCurrency({
|
||||
currencyCode: 'eur',
|
||||
locale: language,
|
||||
value: price,
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<Card
|
||||
key={title}
|
||||
@@ -80,7 +94,7 @@ export default function OrderAnalysesCards({
|
||||
className="px-2 text-black"
|
||||
onClick={() => handleSelect(variant.id)}
|
||||
>
|
||||
{isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
||||
{variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -91,7 +105,14 @@ export default function OrderAnalysesCards({
|
||||
{description && (
|
||||
<>
|
||||
{' '}
|
||||
<InfoTooltip content={`${description}`} />
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span>{formattedPrice}</span>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</h5>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { getProductCategories } from '@lib/data/categories';
|
||||
import { listProductTypes } from '@lib/data/products';
|
||||
import { listProducts, listProductTypes } from '@lib/data/products';
|
||||
import { listRegions } from '@lib/data/regions';
|
||||
|
||||
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
|
||||
import { ServiceCategory } from '../../_components/service-categories';
|
||||
|
||||
async function countryCodesLoader() {
|
||||
const countryCodes = await listRegions().then((regions) =>
|
||||
@@ -39,13 +38,20 @@ async function analysesLoader() {
|
||||
const category = productCategories.find(
|
||||
({ metadata }) => metadata?.page === 'order-analysis',
|
||||
);
|
||||
const categoryProducts = category
|
||||
? await listProducts({
|
||||
countryCode,
|
||||
queryParams: { limit: 100, category_id: category.id },
|
||||
})
|
||||
: null;
|
||||
|
||||
const serviceCategories = productCategories.filter(
|
||||
({ parent_category }) => parent_category?.handle === 'tto-categories',
|
||||
);
|
||||
|
||||
return {
|
||||
analyses:
|
||||
category?.products?.map<OrderAnalysisCard>(
|
||||
categoryProducts?.response.products.map<OrderAnalysisCard>(
|
||||
({ title, description, subtitle, variants, status, metadata }) => {
|
||||
const variant = variants![0]!;
|
||||
return {
|
||||
@@ -57,6 +63,7 @@ async function analysesLoader() {
|
||||
},
|
||||
isAvailable:
|
||||
status === 'published' && !!metadata?.analysisIdOriginal,
|
||||
price: variant.calculated_price?.calculated_amount ?? null,
|
||||
};
|
||||
},
|
||||
) ?? [],
|
||||
|
||||
@@ -16,14 +16,14 @@ export const loadUserAccount = cache(accountLoader);
|
||||
|
||||
export async function loadCurrentUserAccount() {
|
||||
const user = await requireUserInServerComponent();
|
||||
return user?.identities?.[0]?.id
|
||||
? await loadUserAccount(user?.identities?.[0]?.id)
|
||||
return user?.id
|
||||
? await loadUserAccount(user.id)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function accountLoader(accountId: string) {
|
||||
async function accountLoader(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
return api.getAccount(accountId);
|
||||
return api.getPersonalAccountByUserId(userId);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,26 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { medusaLogout } from '@lib/data/customer';
|
||||
|
||||
export const signOutAction = async () => {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
const client = await createClient();
|
||||
|
||||
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('/');
|
||||
};
|
||||
|
||||
@@ -105,12 +105,18 @@ export const createMedusaSyncSuccessEntry = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAnalyses({ ids }: { ids: number[] }): Promise<AnalysesWithGroupsAndElements> {
|
||||
const { data } = await getSupabaseServerAdminClient()
|
||||
export async function getAnalyses({ ids, originalIds }: { ids?: number[], originalIds?: string[] }): Promise<AnalysesWithGroupsAndElements> {
|
||||
const query = getSupabaseServerAdminClient()
|
||||
.schema('medreport')
|
||||
.from('analyses')
|
||||
.select(`*, analysis_elements(*, analysis_groups(*))`)
|
||||
.in('id', ids);
|
||||
.select(`*, analysis_elements(*, analysis_groups(*))`);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getClientInstitution,
|
||||
getClientPerson,
|
||||
getConfidentiality,
|
||||
getOrderEnteredPerson,
|
||||
getPais,
|
||||
getPatient,
|
||||
getProviderInstitution,
|
||||
@@ -553,14 +554,12 @@ export async function composeOrderXML({
|
||||
${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
|
||||
<Tellimus cito="EI">
|
||||
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
||||
<!--<TellijaAsutus>-->
|
||||
${getClientInstitution()}
|
||||
<!--<TeostajaAsutus>-->
|
||||
${getProviderInstitution()}
|
||||
<!--<TellijaIsik>-->
|
||||
${getClientPerson(person)}
|
||||
${getClientPerson()}
|
||||
${getOrderEnteredPerson()}
|
||||
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
|
||||
${getPatient(person)}
|
||||
${getPatient(person)}
|
||||
${getConfidentiality()}
|
||||
${specimenSection.join('')}
|
||||
${analysisSection?.join('')}
|
||||
@@ -666,7 +665,7 @@ async function syncPrivateMessage({
|
||||
unit: element.Mootyhik ?? null,
|
||||
original_response_element: element,
|
||||
analysis_name: element.UuringNimi || element.KNimetus,
|
||||
comment: element.UuringuKommentaar
|
||||
comment: element.UuringuKommentaar ?? '',
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -715,7 +714,7 @@ export async function sendOrderToMedipost({
|
||||
orderedAnalysisElements,
|
||||
}: {
|
||||
medusaOrderId: string;
|
||||
orderedAnalysisElements: { analysisElementId: number }[];
|
||||
orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
|
||||
}) {
|
||||
const medreportOrder = await getOrder({ medusaOrderId });
|
||||
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
|
||||
@@ -727,8 +726,8 @@ export async function sendOrderToMedipost({
|
||||
lastName: account.last_name ?? '',
|
||||
phone: account.phone ?? '',
|
||||
},
|
||||
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId),
|
||||
orderedAnalysesIds: [],
|
||||
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||
orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||
orderId: medusaOrderId,
|
||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||
comment: '',
|
||||
@@ -784,12 +783,13 @@ export async function sendOrderToMedipost({
|
||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
||||
}
|
||||
|
||||
export async function getOrderedAnalysisElementsIds({
|
||||
export async function getOrderedAnalysisIds({
|
||||
medusaOrder,
|
||||
}: {
|
||||
medusaOrder: StoreOrder;
|
||||
}): Promise<{
|
||||
analysisElementId: number;
|
||||
analysisElementId?: number;
|
||||
analysisId?: number;
|
||||
}[]> {
|
||||
const countryCodes = await listRegions();
|
||||
const countryCode = countryCodes[0]!.countries![0]!.iso_2!;
|
||||
@@ -802,6 +802,14 @@ export async function getOrderedAnalysisElementsIds({
|
||||
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) {
|
||||
const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX));
|
||||
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 }));
|
||||
}
|
||||
|
||||
const [analysisPackageElements, orderedAnalysisElements] = await Promise.all([
|
||||
const [analysisPackageElements, orderedAnalysisElements, orderedAnalyses] = await Promise.all([
|
||||
getOrderedAnalysisPackages(medusaOrder),
|
||||
getOrderedAnalysisElements(medusaOrder),
|
||||
getOrderedAnalyses(medusaOrder),
|
||||
]);
|
||||
|
||||
return [...analysisPackageElements, ...orderedAnalysisElements];
|
||||
return [...analysisPackageElements, ...orderedAnalysisElements, ...orderedAnalyses];
|
||||
}
|
||||
|
||||
export async function createMedipostActionLog({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
getClientInstitution,
|
||||
getClientPerson,
|
||||
getOrderEnteredPerson,
|
||||
getPais,
|
||||
getPatient,
|
||||
getProviderInstitution,
|
||||
@@ -104,7 +105,8 @@ export async function composeOrderTestResponseXML({
|
||||
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
||||
${getClientInstitution({ index: 1 })}
|
||||
${getProviderInstitution({ index: 1 })}
|
||||
${getClientPerson(person)}
|
||||
${getClientPerson()}
|
||||
${getOrderEnteredPerson()}
|
||||
<TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused>
|
||||
|
||||
${getPatient(person)}
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function createOrder({
|
||||
orderedAnalysisElements,
|
||||
}: {
|
||||
medusaOrder: StoreOrder;
|
||||
orderedAnalysisElements: { analysisElementId: number }[];
|
||||
orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
|
||||
}) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
@@ -21,8 +21,8 @@ export async function createOrder({
|
||||
const orderResult = await supabase.schema('medreport')
|
||||
.from('analysis_orders')
|
||||
.insert({
|
||||
analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId),
|
||||
analysis_ids: [],
|
||||
analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||
analysis_ids: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||
status: 'QUEUED',
|
||||
user_id: user.id,
|
||||
medusa_order_id: medusaOrder.id,
|
||||
|
||||
@@ -21,70 +21,48 @@ export const getPais = (
|
||||
<Saaja>${recipient}</Saaja>
|
||||
<Aeg>${format(createdAt, DATE_TIME_FORMAT)}</Aeg>
|
||||
<SaadetisId>${orderId}</SaadetisId>
|
||||
<Email>argo@medreport.ee</Email>
|
||||
<Email>info@medreport.ee</Email>
|
||||
</Pais>`;
|
||||
};
|
||||
|
||||
export const getClientInstitution = ({ index }: { index?: number } = {}) => {
|
||||
if (isProd) {
|
||||
// return correct data
|
||||
}
|
||||
return `<Asutus tyyp="TELLIJA" ${index ? ` jarjenumber="${index}"` : ''}>
|
||||
<AsutuseId>16381793</AsutuseId>
|
||||
<AsutuseNimi>MedReport OÜ</AsutuseNimi>
|
||||
<AsutuseKood>TSU</AsutuseKood>
|
||||
<AsutuseKood>MRP</AsutuseKood>
|
||||
<Telefon>+37258871517</Telefon>
|
||||
</Asutus>`;
|
||||
};
|
||||
|
||||
export const getProviderInstitution = ({ index }: { index?: number } = {}) => {
|
||||
if (isProd) {
|
||||
// return correct data
|
||||
}
|
||||
return `<Asutus tyyp="TEOSTAJA" ${index ? ` jarjenumber="${index}"` : ''}>
|
||||
<AsutuseId>11107913</AsutuseId>
|
||||
<AsutuseNimi>Synlab HTI Tallinn</AsutuseNimi>
|
||||
<AsutuseKood>SLA</AsutuseKood>
|
||||
<AsutuseNimi>Synlab Eesti OÜ</AsutuseNimi>
|
||||
<AsutuseKood>HTI</AsutuseKood>
|
||||
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
|
||||
<Telefon>+3723417123</Telefon>
|
||||
<Telefon>+37217123</Telefon>
|
||||
</Asutus>`;
|
||||
};
|
||||
|
||||
export const getClientPerson = ({
|
||||
idCode,
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
}: {
|
||||
idCode: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
phone: string,
|
||||
}) => {
|
||||
if (isProd) {
|
||||
// return correct data
|
||||
}
|
||||
export const getClientPerson = () => {
|
||||
return `<Personal tyyp="TELLIJA" jarjenumber="1">
|
||||
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
|
||||
<PersonalKood>${idCode}</PersonalKood>
|
||||
<PersonalPerekonnaNimi>${lastName}</PersonalPerekonnaNimi>
|
||||
<PersonalEesNimi>${firstName}</PersonalEesNimi>
|
||||
${phone ? `<Telefon>${phone.startsWith('+372') ? phone : `+372${phone}`}</Telefon>` : ''}
|
||||
<PersonalKood>D07907</PersonalKood>
|
||||
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
|
||||
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
|
||||
<Telefon>+37258131202</Telefon>
|
||||
</Personal>`;
|
||||
};
|
||||
|
||||
// export const getOrderEnteredPerson = () => {
|
||||
// if (isProd) {
|
||||
// // return correct data
|
||||
// }
|
||||
// return `<Personal tyyp="SISESTAJA" jarjenumber="1">
|
||||
// <PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
|
||||
// <PersonalKood>D07907</PersonalKood>
|
||||
// <PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
|
||||
// <PersonalEesNimi>Tsvetkov</PersonalEesNimi>
|
||||
// <Telefon>+37258131202</Telefon>
|
||||
// </Personal>`;
|
||||
// };
|
||||
export const getOrderEnteredPerson = () => {
|
||||
return `<Personal tyyp="SISESTAJA" jarjenumber="2">
|
||||
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
|
||||
<PersonalKood>D07907</PersonalKood>
|
||||
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
|
||||
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
|
||||
<Telefon>+37258131202</Telefon>
|
||||
</Personal>`;
|
||||
};
|
||||
|
||||
export const getPatient = ({
|
||||
idCode,
|
||||
|
||||
23
lib/utils.ts
23
lib/utils.ts
@@ -15,11 +15,12 @@ export function toArray<T>(input?: T | T[] | null): T[] {
|
||||
}
|
||||
|
||||
export function toTitleCase(str?: string) {
|
||||
if (!str) return '';
|
||||
return str.replace(
|
||||
/\w\S*/g,
|
||||
(text: string) =>
|
||||
text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||
return (
|
||||
str
|
||||
?.toLowerCase()
|
||||
.replace(/[^-'’\s]+/g, (match) =>
|
||||
match.replace(/^./, (first) => first.toUpperCase()),
|
||||
) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,8 +41,12 @@ export function sortByDate<T>(
|
||||
|
||||
export const bmiFromMetric = (kg: number, cm: number) => {
|
||||
const m = cm / 100;
|
||||
const bmi = kg / (m * m);
|
||||
return bmi ? Math.round(bmi) : NaN;
|
||||
const m2 = m * m;
|
||||
if (m2 === 0) {
|
||||
return null;
|
||||
}
|
||||
const bmi = kg / m2;
|
||||
return !Number.isNaN(bmi) ? Math.round(bmi) : null;
|
||||
};
|
||||
|
||||
export function getBmiStatus(
|
||||
@@ -58,7 +63,9 @@ export function getBmiStatus(
|
||||
) || null;
|
||||
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.strong_min) return BmiCategory.VERY_OVERWEIGHT;
|
||||
|
||||
@@ -50,7 +50,7 @@ const config = {
|
||||
},
|
||||
experimental: {
|
||||
mdxRs: true,
|
||||
reactCompiler: ENABLE_REACT_COMPILER,
|
||||
reactCompiler: false,
|
||||
optimizePackageImports: [
|
||||
'recharts',
|
||||
'lucide-react',
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"isikukood": "3.1.7",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"libphonenumber-js": "^1.12.15",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
|
||||
@@ -79,15 +79,9 @@ export function PersonalAccountDropdown({
|
||||
}) {
|
||||
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||
|
||||
const signedInAsLabel = useMemo(() => {
|
||||
const email = user?.email ?? undefined;
|
||||
const phone = user?.phone ?? undefined;
|
||||
|
||||
return email ?? phone;
|
||||
}, [user]);
|
||||
|
||||
const displayName =
|
||||
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
|
||||
const { name, last_name } = personalAccountData ?? {};
|
||||
const firstNameLabel = toTitleCase(name) ?? '-';
|
||||
const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
|
||||
|
||||
const hasTotpFactor = useMemo(() => {
|
||||
const factors = user?.factors ?? [];
|
||||
@@ -128,7 +122,7 @@ export function PersonalAccountDropdown({
|
||||
<ProfileAvatar
|
||||
className={'rounded-md'}
|
||||
fallbackClassName={'rounded-md border'}
|
||||
displayName={displayName ?? user?.email ?? ''}
|
||||
displayName={firstNameLabel}
|
||||
pictureUrl={personalAccountData?.picture_url}
|
||||
/>
|
||||
|
||||
@@ -142,7 +136,7 @@ export function PersonalAccountDropdown({
|
||||
data-test={'account-dropdown-display-name'}
|
||||
className={'truncate text-sm'}
|
||||
>
|
||||
{toTitleCase(displayName)}
|
||||
{firstNameLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={'block truncate'}>{signedInAsLabel}</span>
|
||||
<span className={'block truncate'}>{fullNameLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -48,6 +48,41 @@ class AccountsApi {
|
||||
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
|
||||
* @description Get the account workspace data.
|
||||
|
||||
@@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button';
|
||||
* @see https://supabase.com/docs/guides/auth/social-login
|
||||
*/
|
||||
const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
|
||||
azure: 'email',
|
||||
keycloak: 'openid',
|
||||
// azure: 'email',
|
||||
// keycloak: 'openid',
|
||||
// add your OAuth providers here
|
||||
};
|
||||
|
||||
@@ -88,10 +88,12 @@ export const OauthProviders: React.FC<{
|
||||
queryParams.set('invite_token', props.inviteToken);
|
||||
}
|
||||
|
||||
const redirectPath = [
|
||||
props.paths.callback,
|
||||
queryParams.toString(),
|
||||
].join('?');
|
||||
// signicat/keycloak will not allow redirect-uri with changing query params
|
||||
const INCLUDE_QUERY_PARAMS = false as boolean;
|
||||
|
||||
const redirectPath = INCLUDE_QUERY_PARAMS
|
||||
? [props.paths.callback, queryParams.toString()].join('?')
|
||||
: props.paths.callback;
|
||||
|
||||
const redirectTo = [origin, redirectPath].join('');
|
||||
const scopes = OAUTH_SCOPES[provider] ?? undefined;
|
||||
@@ -102,6 +104,7 @@ export const OauthProviders: React.FC<{
|
||||
redirectTo,
|
||||
queryParams: props.queryParams,
|
||||
scopes,
|
||||
// skipBrowserRedirect: false,
|
||||
},
|
||||
} satisfies SignInWithOAuthCredentials;
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ export function SignInMethodsContainer(props: {
|
||||
callback: props.paths.callback,
|
||||
returnPath: props.paths.returnPath,
|
||||
}}
|
||||
queryParams={{
|
||||
prompt: 'login',
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SignUpMethodsContainer(props: {
|
||||
emailRedirectTo={props.paths.callback}
|
||||
defaultValues={defaultValues}
|
||||
displayTermsCheckbox={props.displayTermsCheckbox}
|
||||
onSignUp={() => redirect(redirectUrl)}
|
||||
//onSignUp={() => redirect(redirectUrl)}
|
||||
/>
|
||||
</If>
|
||||
|
||||
@@ -79,6 +79,9 @@ export function SignUpMethodsContainer(props: {
|
||||
callback: props.paths.callback,
|
||||
returnPath: props.paths.appHome,
|
||||
}}
|
||||
queryParams={{
|
||||
prompt: 'login',
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
|
||||
@@ -87,7 +87,10 @@ export async function getOrSetCart(countryCode: string) {
|
||||
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());
|
||||
|
||||
if (!cartId) {
|
||||
@@ -109,9 +112,13 @@ export async function updateCart({ id, ...data }: HttpTypes.StoreUpdateCart & {
|
||||
const fulfillmentCacheTag = await getCacheTag("fulfillment");
|
||||
revalidateTag(fulfillmentCacheTag);
|
||||
|
||||
onSuccess();
|
||||
return cart;
|
||||
})
|
||||
.catch(medusaError);
|
||||
.catch((e) => {
|
||||
onError();
|
||||
return medusaError(e);
|
||||
});
|
||||
}
|
||||
|
||||
export async function addToCart({
|
||||
@@ -259,7 +266,10 @@ export async function initiatePaymentSession(
|
||||
.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();
|
||||
|
||||
if (!cartId) {
|
||||
@@ -278,8 +288,13 @@ export async function applyPromotions(codes: string[]) {
|
||||
|
||||
const fulfillmentCacheTag = await getCacheTag("fulfillment");
|
||||
revalidateTag(fulfillmentCacheTag);
|
||||
|
||||
onSuccess();
|
||||
})
|
||||
.catch(medusaError);
|
||||
.catch((e) => {
|
||||
onError();
|
||||
return medusaError(e);
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyGiftCard(code: string) {
|
||||
@@ -427,7 +442,7 @@ export async function placeOrder(cartId?: string, options: { revalidateCacheTags
|
||||
} else {
|
||||
throw new Error("Cart is not an order");
|
||||
}
|
||||
|
||||
|
||||
return retrieveOrder(cartRes.order.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { sdk } from "@lib/config"
|
||||
import medusaError from "@lib/util/medusa-error"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { revalidateTag } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
import {
|
||||
getAuthHeaders,
|
||||
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 removeAuthToken()
|
||||
@@ -139,8 +138,6 @@ export async function signout(countryCode: string) {
|
||||
|
||||
const cartCacheTag = await getCacheTag("carts")
|
||||
revalidateTag(cartCacheTag)
|
||||
|
||||
redirect(`/${countryCode}/account`)
|
||||
}
|
||||
|
||||
export async function transferCart() {
|
||||
@@ -260,62 +257,110 @@ export const updateCustomerAddress = async (
|
||||
})
|
||||
}
|
||||
|
||||
export async function medusaLoginOrRegister(credentials: {
|
||||
email: string
|
||||
password?: string
|
||||
}) {
|
||||
const { email, password } = credentials;
|
||||
async function medusaLogin(email: string, password: string) {
|
||||
const token = await sdk.auth.login("customer", "emailpass", { email, password });
|
||||
await setAuthToken(token as string);
|
||||
|
||||
try {
|
||||
const token = await sdk.auth.login("customer", "emailpass", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
await setAuthToken(token as string);
|
||||
await transferCart();
|
||||
} catch (e) {
|
||||
console.error("Failed to transfer cart", e);
|
||||
}
|
||||
|
||||
const customerCacheTag = await getCacheTag("customers");
|
||||
revalidateTag(customerCacheTag);
|
||||
const customer = await retrieveCustomer();
|
||||
if (!customer) {
|
||||
throw new Error("Customer not found for active session");
|
||||
}
|
||||
|
||||
const customer = await retrieveCustomer();
|
||||
if (!customer) {
|
||||
throw new Error("Customer not found");
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error("Failed to login customer, attempting to register", error);
|
||||
|
||||
return generateDeterministicPassword(email, supabaseUserId);
|
||||
})();
|
||||
|
||||
try {
|
||||
return await medusaLogin(email, password);
|
||||
} catch (loginError) {
|
||||
console.error("Failed to login customer, attempting to register", loginError);
|
||||
|
||||
try {
|
||||
const registerToken = await sdk.auth.register("customer", "emailpass", {
|
||||
email: email,
|
||||
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;
|
||||
await medusaRegister({ email, password, name, lastName });
|
||||
return await medusaLogin(email, password);
|
||||
} catch (registerError) {
|
||||
console.error("Failed to create Medusa account for user with email=${email}", 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!`;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const listProducts = async ({
|
||||
regionId,
|
||||
}: {
|
||||
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
|
||||
regionId?: string
|
||||
}): Promise<{
|
||||
@@ -63,7 +63,7 @@ export const listProducts = async ({
|
||||
offset,
|
||||
region_id: region?.id,
|
||||
fields:
|
||||
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
|
||||
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,+status",
|
||||
...queryParams,
|
||||
},
|
||||
headers,
|
||||
|
||||
@@ -10,7 +10,7 @@ import MapPin from "@modules/common/icons/map-pin"
|
||||
import Package from "@modules/common/icons/package"
|
||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { signout } from "@lib/data/customer"
|
||||
import { medusaLogout } from "@lib/data/customer"
|
||||
|
||||
const AccountNav = ({
|
||||
customer,
|
||||
@@ -21,7 +21,7 @@ const AccountNav = ({
|
||||
const { countryCode } = useParams() as { countryCode: string }
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signout(countryCode)
|
||||
await medusaLogout(countryCode)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ export function InfoTooltip({
|
||||
content,
|
||||
icon,
|
||||
}: {
|
||||
content?: string | null;
|
||||
content?: JSX.Element | string | null;
|
||||
icon?: JSX.Element;
|
||||
}) {
|
||||
if (!content) return null;
|
||||
@@ -23,7 +23,7 @@ export function InfoTooltip({
|
||||
<TooltipTrigger>
|
||||
{icon || <Info className="size-4 cursor-pointer" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{content}</TooltipContent>
|
||||
<TooltipContent className='sm:max-w-[400px]'>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ const authConfig = AuthConfigSchema.parse({
|
||||
providers: {
|
||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||
oAuth: ['google'],
|
||||
oAuth: ['keycloak'],
|
||||
},
|
||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AuthError,
|
||||
type EmailOtpType,
|
||||
SupabaseClient,
|
||||
User,
|
||||
} from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) {
|
||||
* @description Service for handling auth callbacks in Supabase
|
||||
*/
|
||||
class AuthCallbackService {
|
||||
constructor(private readonly client: SupabaseClient) {}
|
||||
constructor(private readonly client: SupabaseClient) { }
|
||||
|
||||
/**
|
||||
* @name verifyTokenHash
|
||||
@@ -128,89 +129,117 @@ class AuthCallbackService {
|
||||
/**
|
||||
* @name exchangeCodeForSession
|
||||
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
|
||||
* @param request
|
||||
* @param params
|
||||
* @param authCode
|
||||
*/
|
||||
async exchangeCodeForSession(
|
||||
request: Request,
|
||||
params: {
|
||||
joinTeamPath: string;
|
||||
redirectPath: string;
|
||||
errorPath?: string;
|
||||
},
|
||||
): Promise<{
|
||||
nextPath: string;
|
||||
}> {
|
||||
const requestUrl = new URL(request.url);
|
||||
const searchParams = requestUrl.searchParams;
|
||||
async exchangeCodeForSession(authCode: string): Promise<{
|
||||
isSuccess: boolean;
|
||||
user: User;
|
||||
} | ErrorURLParameters> {
|
||||
let user: User;
|
||||
try {
|
||||
const { data, error } =
|
||||
await this.client.auth.exchangeCodeForSession(authCode);
|
||||
|
||||
const authCode = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const nextUrlPathFromParams = searchParams.get('next');
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||
|
||||
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 we have an error, we redirect to the error page
|
||||
if (error) {
|
||||
return getErrorURLParameters({
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return onError({
|
||||
error,
|
||||
path: errorPath,
|
||||
// Handle Keycloak users - set up Medusa integration
|
||||
if (data?.user && this.isKeycloakUser(data.user)) {
|
||||
await this.setupMedusaUserForKeycloak(data.user);
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
||||
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,
|
||||
path,
|
||||
code,
|
||||
}: {
|
||||
error: string;
|
||||
path: string;
|
||||
code?: string;
|
||||
}) {
|
||||
}): ErrorURLParameters {
|
||||
const errorMessage = getAuthErrorMessage({ error, code });
|
||||
|
||||
console.error(
|
||||
@@ -255,10 +288,10 @@ function onError({
|
||||
code: code ?? '',
|
||||
});
|
||||
|
||||
const nextPath = `${path}?${searchParams.toString()}`;
|
||||
|
||||
return {
|
||||
nextPath,
|
||||
error: errorMessage,
|
||||
code: code ?? '',
|
||||
searchParams: searchParams.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
||||
export function getSupabaseBrowserClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey);
|
||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ export function createMiddlewareClient<GenericSchema = Database>(
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
|
||||
@@ -15,6 +15,11 @@ export function getSupabaseServerClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
cookies: {
|
||||
async getAll() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
@@ -28,6 +28,7 @@ export function useSignInWithEmailPassword() {
|
||||
const medusaAccountId = await medusaLoginOrRegister({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
isDevPasswordLogin: true,
|
||||
});
|
||||
await client
|
||||
.schema('medreport').from('accounts')
|
||||
|
||||
@@ -9,7 +9,13 @@ export function useSignInWithProvider() {
|
||||
const mutationKey = ['auth', 'sign-in-with-provider'];
|
||||
|
||||
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) {
|
||||
throw response.error.message;
|
||||
|
||||
@@ -6,8 +6,23 @@ export function useSignOut() {
|
||||
const client = useSupabase();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => {
|
||||
return client.auth.signOut();
|
||||
mutationFn: async () => {
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function useSignUpWithEmailAndPassword() {
|
||||
const medusaAccountId = await medusaLoginOrRegister({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
isDevPasswordLogin: true,
|
||||
});
|
||||
await client
|
||||
.schema('medreport').from('accounts')
|
||||
|
||||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
@@ -128,6 +128,9 @@ importers:
|
||||
jsonwebtoken:
|
||||
specifier: 9.0.2
|
||||
version: 9.0.2
|
||||
libphonenumber-js:
|
||||
specifier: ^1.12.15
|
||||
version: 1.12.15
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
@@ -475,10 +478,10 @@ importers:
|
||||
dependencies:
|
||||
'@keystatic/core':
|
||||
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':
|
||||
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':
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.4(@types/react@19.1.4)(react@19.1.0)
|
||||
@@ -1269,7 +1272,7 @@ importers:
|
||||
dependencies:
|
||||
'@sentry/nextjs':
|
||||
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:
|
||||
specifier: 1.13.2
|
||||
version: 1.13.2
|
||||
@@ -8174,6 +8177,9 @@ packages:
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
libphonenumber-js@1.12.15:
|
||||
resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==}
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -11455,7 +11461,7 @@ snapshots:
|
||||
|
||||
'@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:
|
||||
'@babel/runtime': 7.27.6
|
||||
'@emotion/css': 11.13.5
|
||||
@@ -11548,18 +11554,18 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
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:
|
||||
- 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:
|
||||
'@babel/runtime': 7.27.6
|
||||
'@braintree/sanitize-url': 6.0.4
|
||||
'@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)
|
||||
'@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)
|
||||
'@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)
|
||||
@@ -11630,13 +11636,13 @@ snapshots:
|
||||
- next
|
||||
- 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:
|
||||
'@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
|
||||
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-dom: 19.1.0(react@19.1.0)
|
||||
server-only: 0.0.1
|
||||
@@ -17230,7 +17236,7 @@ snapshots:
|
||||
|
||||
'@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:
|
||||
'@opentelemetry/api': 1.9.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/webpack-plugin': 3.5.0(webpack@5.101.3)
|
||||
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
|
||||
rollup: 4.35.0
|
||||
stacktrace-parser: 0.1.11
|
||||
@@ -20630,6 +20636,8 @@ snapshots:
|
||||
dependencies:
|
||||
isomorphic.js: 0.2.5
|
||||
|
||||
libphonenumber-js@1.12.15: {}
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
optional: true
|
||||
|
||||
@@ -21265,31 +21273,6 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- 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:
|
||||
dependencies:
|
||||
lower-case: 2.0.2
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"emptyCartMessageDescription": "Add items to your cart to continue.",
|
||||
"subtotal": "Subtotal",
|
||||
"total": "Total",
|
||||
"promotionsTotal": "Promotions total",
|
||||
"table": {
|
||||
"item": "Item",
|
||||
"quantity": "Quantity",
|
||||
@@ -24,10 +25,13 @@
|
||||
"timeoutAction": "Continue"
|
||||
},
|
||||
"discountCode": {
|
||||
"title": "Gift card or promotion code",
|
||||
"label": "Add Promotion Code(s)",
|
||||
"apply": "Apply",
|
||||
"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": {
|
||||
"synlabAnalyses": {
|
||||
|
||||
@@ -128,6 +128,9 @@
|
||||
"amount": "Amount",
|
||||
"selectDate": "Select date"
|
||||
},
|
||||
"formFieldError": {
|
||||
"invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)"
|
||||
},
|
||||
"wallet": {
|
||||
"balance": "Your MedReport account balance",
|
||||
"expiredAt": "Valid until {{expiredAt}}"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"emptyCartMessage": "Sinu ostukorv on tühi",
|
||||
"emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.",
|
||||
"subtotal": "Vahesumma",
|
||||
"promotionsTotal": "Soodustuse summa",
|
||||
"total": "Summa",
|
||||
"table": {
|
||||
"item": "Toode",
|
||||
@@ -28,7 +29,13 @@
|
||||
"label": "Lisa promo kood",
|
||||
"apply": "Rakenda",
|
||||
"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": {
|
||||
"synlabAnalyses": {
|
||||
|
||||
@@ -128,6 +128,9 @@
|
||||
"amount": "Summa",
|
||||
"selectDate": "Vali kuupäev"
|
||||
},
|
||||
"formFieldError": {
|
||||
"invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)"
|
||||
},
|
||||
"wallet": {
|
||||
"balance": "Sinu MedReporti konto saldo",
|
||||
"expiredAt": "Kehtiv kuni {{expiredAt}}"
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user