52 Commits

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

* update zod

* move some components to shared

* move some components to shared

* remove console.logs

* remove unused password form components

* only check null for variant

* use pathsconfig
2025-09-04 12:17:54 +03:00
Danel Kungla
152ec5f36b fix query response cant be undefined 2025-09-04 10:59:34 +03:00
Danel Kungla
5176ecdddc hide search and wallet 2025-09-04 10:56:24 +03:00
1de564b917 rerun codepipeline 2025-09-03 15:15:02 +03:00
a2c080914a Merge branch 'develop' 2025-09-03 13:32:15 +03:00
Danel Kungla
94dd00b9ca Add data.sql to .gitignore 2025-08-29 18:05:29 +03:00
134 changed files with 5449 additions and 4346 deletions

5
.env
View File

@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a" NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
# AUTH # AUTH
NEXT_PUBLIC_AUTH_PASSWORD=true NEXT_PUBLIC_AUTH_PASSWORD=false
NEXT_PUBLIC_AUTH_MAGIC_LINK=false NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY= NEXT_PUBLIC_CAPTCHA_SITE_KEY=
@@ -65,3 +65,6 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
# Configure Medusa password secret for Keycloak users
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,20 @@
import { z } from 'zod'; import { z } from 'zod';
import parsePhoneNumber from 'libphonenumber-js/min';
export const UpdateAccountSchema = z.object({ export const UpdateAccountSchema = z.object({
firstName: z firstName: z
.string({ .string({
required_error: 'First name is required', error: 'First name is required',
}) })
.nonempty(), .nonempty(),
lastName: z lastName: z
.string({ .string({
required_error: 'Last name is required', error: 'Last name is required',
}) })
.nonempty(), .nonempty(),
personalCode: z personalCode: z
.string({ .string({
required_error: 'Personal code is required', error: 'Personal code is required',
}) })
.nonempty(), .nonempty(),
email: z.string().email({ email: z.string().email({
@@ -21,21 +22,38 @@ export const UpdateAccountSchema = z.object({
}), }),
phone: z phone: z
.string({ .string({
required_error: 'Phone number is required', error: 'Phone number is required',
}) })
.nonempty(), .nonempty()
.refine(
(phone) => {
try {
const phoneNumber = parsePhoneNumber(phone);
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPhoneNumber',
}
),
city: z.string().optional(), city: z.string().optional(),
weight: z weight: z
.number({ .number({
required_error: 'Weight is required', error: (issue) =>
invalid_type_error: 'Weight must be a number', issue.input === undefined
? 'Weight is required'
: 'Weight must be a number',
}) })
.gt(0, { message: 'Weight must be greater than 0' }), .gt(0, { message: 'Weight must be greater than 0' }),
height: z height: z
.number({ .number({
required_error: 'Height is required', error: (issue) =>
invalid_type_error: 'Height must be a number', issue.input === undefined
? 'Height is required'
: 'Height must be a number',
}) })
.gt(0, { message: 'Height must be greater than 0' }), .gt(0, { message: 'Height must be greater than 0' }),
userConsent: z.boolean().refine((val) => val === true, { userConsent: z.boolean().refine((val) => val === true, {

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,11 @@
import { use } from 'react'; import { use } from 'react';
import { cookies } from 'next/headers';
import { retrieveCart } from '@lib/data/cart'; import { retrieveCart } from '@lib/data/cart';
import { StoreCart } from '@medusajs/types'; import { StoreCart } from '@medusajs/types';
import { z } from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components'; import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
import { import { pathsConfig } from '@kit/shared/config';
pathsConfig,
personalAccountNavigationConfig,
} from '@kit/shared/config';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
@@ -24,40 +18,11 @@ import { HomeSidebar } from '../_components/home-sidebar';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace'; import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
function UserHomeLayout({ children }: React.PropsWithChildren) { function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState());
if (state.style === 'sidebar') {
return <SidebarLayout>{children}</SidebarLayout>;
}
return <HeaderLayout>{children}</HeaderLayout>; return <HeaderLayout>{children}</HeaderLayout>;
} }
export default withI18n(UserHomeLayout); export default withI18n(UserHomeLayout);
function SidebarLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const state = use(getLayoutState());
return (
<UserWorkspaceContextProvider value={workspace}>
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<HomeSidebar />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} cart={null} />
</PageMobileNavigation>
{children}
</Page>
</SidebarProvider>
</UserWorkspaceContextProvider>
);
}
function HeaderLayout({ children }: React.PropsWithChildren) { function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace()); const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart()); const cart = use(retrieveCart());
@@ -101,27 +66,3 @@ function MobileNavigation({
</> </>
); );
} }
async function getLayoutState() {
const cookieStore = await cookies();
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
const layoutStyleCookie = cookieStore.get('layout-style');
const sidebarOpenCookie = cookieStore.get('sidebar:state');
const sidebarOpen = sidebarOpenCookie
? sidebarOpenCookie.value === 'false'
: !personalAccountNavigationConfig.sidebarCollapsed;
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
const style = parsedStyle.success
? parsedStyle.data
: personalAccountNavigationConfig.style;
return {
open: sidebarOpen,
style,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,33 +7,41 @@ import { Database } from '@/packages/supabase/src/database.types';
import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons'; import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons';
import { import {
Activity, Activity,
ChevronRight,
Clock9, Clock9,
Droplets,
Pill, Pill,
Scale, Scale,
TrendingUp, TrendingUp,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip'; import { pathsConfig } from '@kit/shared/config';
import { getPersonParameters } from '@kit/shared/utils'; import { getPersonParameters } from '@kit/shared/utils';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
Card, Card,
CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardProps,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { isNil } from 'lodash';
import { BmiCategory } from '~/lib/types/bmi'; import { BmiCategory } from '~/lib/types/bmi';
import { import {
bmiFromMetric, bmiFromMetric,
getBmiBackgroundColor, getBmiBackgroundColor,
getBmiStatus, getBmiStatus,
} from '~/lib/utils'; } from '~/lib/utils';
import DashboardRecommendations from './dashboard-recommendations';
const getCardVariant = (isSuccess: boolean | null): CardProps['variant'] => {
if (isSuccess === null) return 'default';
if (isSuccess) return 'gradient-success';
return 'gradient-destructive';
};
const cards = ({ const cards = ({
gender, gender,
@@ -41,111 +49,91 @@ const cards = ({
height, height,
weight, weight,
bmiStatus, bmiStatus,
smoking,
}: { }: {
gender?: string; gender?: string;
age?: number; age?: number;
height?: number | null; height?: number | null;
weight?: number | null; weight?: number | null;
bmiStatus: BmiCategory | null; bmiStatus: BmiCategory | null;
smoking?: boolean | null;
}) => [ }) => [
{ {
title: 'dashboard:gender', title: 'dashboard:gender',
description: gender ?? 'dashboard:male', description: gender ?? 'dashboard:male',
icon: <User />, icon: <User />,
iconBg: 'bg-success', iconBg: 'bg-success',
}, },
{ {
title: 'dashboard:age', title: 'dashboard:age',
description: age ? `${age}` : '-', description: age ? `${age}` : '-',
icon: <Clock9 />, icon: <Clock9 />,
iconBg: 'bg-success', iconBg: 'bg-success',
}, },
{ {
title: 'dashboard:height', title: 'dashboard:height',
description: height ? `${height}cm` : '-', description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />, icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success', iconBg: 'bg-success',
}, },
{ {
title: 'dashboard:weight', title: 'dashboard:weight',
description: weight ? `${weight}kg` : '-', description: weight ? `${weight}kg` : '-',
icon: <Scale />, icon: <Scale />,
iconBg: 'bg-success', iconBg: 'bg-success',
}, },
{ {
title: 'dashboard:bmi', title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(), description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
icon: <TrendingUp />, icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus), iconBg: getBmiBackgroundColor(bmiStatus),
}, },
{ {
title: 'dashboard:bloodPressure', title: 'dashboard:bloodPressure',
description: '-', description: '-',
icon: <Activity />, icon: <Activity />,
iconBg: 'bg-warning', iconBg: 'bg-warning',
}, },
{ {
title: 'dashboard:cholesterol', title: 'dashboard:cholesterol',
description: '-', description: '-',
icon: <BlendingModeIcon className="size-4" />, icon: <BlendingModeIcon className="size-4" />,
iconBg: 'bg-destructive', iconBg: 'bg-destructive',
}, },
{ {
title: 'dashboard:ldlCholesterol', title: 'dashboard:ldlCholesterol',
description: '-', description: '-',
icon: <Pill />, icon: <Pill />,
iconBg: 'bg-warning', iconBg: 'bg-warning',
}, },
// { // {
// title: 'Score 2', // title: 'Score 2',
// description: 'Normis', // description: 'Normis',
// icon: <LineChart />, // icon: <LineChart />,
// iconBg: 'bg-success', // iconBg: 'bg-success',
// }, // },
// { {
// title: 'dashboard:smoking', title: 'dashboard:smoking',
// description: 'dashboard:respondToQuestion', description:
// descriptionColor: 'text-primary', isNil(smoking)
// icon: ( ? 'dashboard:respondToQuestion'
// <Button size="icon" variant="outline" className="px-2 text-black"> : !!smoking
// <ChevronRight className="size-4 stroke-2" /> ? 'common:yes'
// </Button> : 'common:no',
// ), descriptionColor: 'text-primary',
// cardVariant: 'gradient-success' as CardProps['variant'], icon:
// }, isNil(smoking) ? (
]; <Link href={pathsConfig.app.personalAccountSettings}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
) : null,
cardVariant: getCardVariant(isNil(smoking) ? null : !smoking),
},
];
const dummyRecommendations = [ const IS_SHOWN_RECOMMENDATIONS = false as boolean;
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-cyan/10 text-cyan',
title: 'Kolesterooli kontroll',
description: 'HDL-kolestrool',
tooltipContent: 'Selgitus',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-primary/10 text-primary',
title: 'Kolesterooli kontroll',
tooltipContent: 'Selgitus',
description: 'LDL-Kolesterool',
buttonText: 'Broneeri',
href: '/home/booking',
},
{
icon: <Droplets />,
color: 'bg-destructive/10 text-destructive',
title: 'Vererõhu kontroll',
tooltipContent: 'Selgitus',
description: 'Score-Risk 2',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
];
export default function Dashboard({ export default function Dashboard({
account, account,
@@ -160,8 +148,8 @@ export default function Dashboard({
const params = getPersonParameters(account.personal_code!); const params = getPersonParameters(account.personal_code!);
const bmiStatus = getBmiStatus(bmiThresholds, { const bmiStatus = getBmiStatus(bmiThresholds, {
age: params?.age || 0, age: params?.age || 0,
height: account.account_params?.[0]?.height || 0, height: account.accountParams?.height || 0,
weight: account.account_params?.[0]?.weight || 0, weight: account.accountParams?.weight || 0,
}); });
return ( return (
@@ -170,21 +158,22 @@ export default function Dashboard({
{cards({ {cards({
gender: params?.gender, gender: params?.gender,
age: params?.age, age: params?.age,
height: account.account_params?.[0]?.height, height: account.accountParams?.height,
weight: account.account_params?.[0]?.weight, weight: account.accountParams?.weight,
bmiStatus, bmiStatus,
smoking: account.accountParams?.isSmoker,
}).map( }).map(
({ ({
title, title,
description, description,
icon, icon,
iconBg, iconBg,
// cardVariant, cardVariant,
// descriptionColor, // descriptionColor,
}) => ( }) => (
<Card <Card
key={title} key={title}
// variant={cardVariant} variant={cardVariant}
className="flex flex-col justify-between" className="flex flex-col justify-between"
> >
<CardHeader className="items-end-safe"> <CardHeader className="items-end-safe">
@@ -211,79 +200,7 @@ export default function Dashboard({
), ),
)} )}
</div> </div>
<Card> {IS_SHOWN_RECOMMENDATIONS && <DashboardRecommendations />}
<CardHeader className="items-start">
<h4>
<Trans i18nKey="dashboard:recommendedForYou" />
</h4>
</CardHeader>
<CardContent className="space-y-6">
{dummyRecommendations.map(
(
{
icon,
color,
title,
description,
tooltipContent,
price,
buttonText,
href,
},
index,
) => {
return (
<div
className="flex w-full justify-between gap-3 overflow-scroll"
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
color,
)}
>
{icon}
</div>
<div className="min-w-fit">
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title}
<InfoTooltip content={tooltipContent} />
</div>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
</div>
<div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4 min-w-fit">
<p className="text-sm font-medium"> {price}</p>
{href ? (
<Link href={href}>
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
</Link>
) : (
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
)}
</div>
</div>
);
},
)}
</CardContent>
</Card>
</> </>
); );
} }

View File

@@ -31,7 +31,8 @@ export async function HomeMenuNavigation(props: {
}) })
: 0; : 0;
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0; const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0; const hasCartItems = cartQuantityTotal > 0;
return ( return (
@@ -39,15 +40,18 @@ export async function HomeMenuNavigation(props: {
<div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}> <div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}>
<AppLogo href={pathsConfig.app.home} /> <AppLogo href={pathsConfig.app.home} />
</div> </div>
<Search {/* TODO: add search functionality */}
{/* <Search
className="flex grow" className="flex grow"
startElement={<Trans i18nKey="common:search" values={{ end: '...' }} />} startElement={<Trans i18nKey="common:search" values={{ end: '...' }} />}
/> /> */}
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
{/* TODO: add wallet functionality
<Card className="px-6 py-2"> <Card className="px-6 py-2">
<span>€ {Number(0).toFixed(2).replace('.', ',')}</span> <span>€ {Number(0).toFixed(2).replace('.', ',')}</span>
</Card> </Card>
*/}
{hasCartItems && ( {hasCartItems && (
<Button <Button
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2" className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
@@ -56,7 +60,7 @@ export async function HomeMenuNavigation(props: {
<span className="flex items-center text-nowrap">{totalValue}</span> <span className="flex items-center text-nowrap">{totalValue}</span>
</Button> </Button>
)} )}
<Link href="/home/cart"> <Link href={pathsConfig.app.cart}>
<Button <Button
variant="ghost" variant="ghost"
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2" className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"

View File

@@ -4,11 +4,13 @@ import { useMemo } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import { StoreCart } from '@medusajs/types'; import { StoreCart } from '@medusajs/types';
import { Cross, LogOut, Menu, Shield, ShoppingCart } from 'lucide-react'; import { Cross, Menu, Shield, ShoppingCart } from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data'; import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts'; import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import { import {
pathsConfig, pathsConfig,
personalAccountNavigationConfig, personalAccountNavigationConfig,
@@ -91,7 +93,7 @@ export function HomeMobileNavigation(props: {
<If condition={props.cart && hasCartItems}> <If condition={props.cart && hasCartItems}>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownLink <DropdownLink
path="/home/cart" path={pathsConfig.app.cart}
label="common:shoppingCartCount" label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />} Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartQuantityTotal }} labelOptions={{ count: cartQuantityTotal }}
@@ -145,49 +147,4 @@ export function HomeMobileNavigation(props: {
); );
} }
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
labelOptions?: Record<string, any>;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem asChild key={props.path}>
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans
i18nKey={props.label}
defaults={props.label}
values={props.labelOptions}
/>
</span>
</Link>
</DropdownMenuItem>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

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

View File

@@ -1,6 +1,5 @@
import { cache } from 'react'; import { cache } from 'react';
import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component';
import { createAccountsApi } from '@kit/accounts/api'; import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Trans } from 'react-i18next';
import { AccountWithParams } from '@kit/accounts/api';
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
import { Button } from '@kit/ui/button';
import { Card, CardDescription, CardTitle } from '@kit/ui/card';
import { Form } from '@kit/ui/form';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
import {
AccountPreferences,
accountPreferencesSchema,
} from '../_lib/account-preferences.schema';
import { updatePersonalAccountPreferencesAction } from '../_lib/server/actions';
import { LanguageSelector } from '@kit/ui/language-selector';
export default function AccountPreferencesForm({
account,
}: {
account: AccountWithParams | null;
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const form = useForm({
resolver: zodResolver(accountPreferencesSchema),
defaultValues: {
preferredLanguage: account?.preferred_locale,
isConsentToAnonymizedCompanyStatistics:
!!account?.has_consent_anonymized_company_statistics,
},
});
const { register, handleSubmit, watch, setValue } = form;
const onSubmit = async (data: AccountPreferences) => {
if (!account?.id) {
return toast.error(<Trans i18nKey="account:updateAccountError" />);
}
const result = await updatePersonalAccountPreferencesAction({
accountId: account.id,
data,
});
if (result.success) {
revalidateUserDataQuery(account.primary_owner_user_id);
return toast.success(
<Trans i18nKey="account:updateAccountPreferencesSuccess" />,
);
}
return toast.error(
<Trans i18nKey="account:updateAccountPreferencesError" />,
);
};
const watchedConsent = watch('isConsentToAnonymizedCompanyStatistics');
if (!account) return null;
return (
<>
<div className="space-y-2">
<CardTitle className="text-base">
<Trans i18nKey={'account:language'} />
</CardTitle>
<LanguageSelector />
</div>
<Form {...form}>
<form
className="flex flex-col gap-6 text-left"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
<Trans i18nKey="account:consents" />
</h2>
<Card>
<div className="flex items-center justify-between p-3">
<div>
<CardTitle className="text-base">
<Trans i18nKey="account:consentToAnonymizedCompanyData.label" />
</CardTitle>
<CardDescription>
<Trans i18nKey="account:consentToAnonymizedCompanyData.description" />
</CardDescription>
</div>
<Switch
checked={!!watchedConsent}
onCheckedChange={(checked) =>
setValue('isConsentToAnonymizedCompanyStatistics', checked)
}
{...register('isConsentToAnonymizedCompanyStatistics')}
/>
</div>
</Card>
</div>
<Button type="submit" className="w-36">
<Trans i18nKey="account:updateProfileSubmitLabel" />
</Button>
</form>
</Form>
</>
);
}

View File

@@ -0,0 +1,259 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Trans } from 'react-i18next';
import { AccountWithParams } from '@kit/accounts/api';
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
import { Button } from '@kit/ui/button';
import { Card, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
import {
AccountSettings,
accountSettingsSchema,
} from '../_lib/account-settings.schema';
import { updatePersonalAccountAction } from '../_lib/server/actions';
export default function AccountSettingsForm({
account,
}: {
account: AccountWithParams | null;
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const form = useForm({
resolver: zodResolver(accountSettingsSchema),
defaultValues: {
firstName: account?.name,
lastName: account?.last_name ?? '',
email: account?.email,
phone: account?.phone ?? '',
accountParams: {
height: account?.accountParams?.height,
weight: account?.accountParams?.weight,
isSmoker: account?.accountParams?.isSmoker,
},
},
});
const { handleSubmit } = form;
const onSubmit = async (data: AccountSettings) => {
if (!account?.id) {
return toast.error(<Trans i18nKey="account:updateAccountError" />);
}
const result = await updatePersonalAccountAction({
accountId: account.id,
data,
});
if (result.success) {
revalidateUserDataQuery(account.primary_owner_user_id);
return toast.success(<Trans i18nKey="account:updateAccountSuccess" />);
}
return toast.error(<Trans i18nKey="account:updateAccountError" />);
};
if (!account) return null;
return (
<Form {...form}>
<form
className="flex flex-col gap-6 text-left"
onSubmit={handleSubmit(onSubmit)}
>
<FormField
name={'firstName'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.firstName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'lastName'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.lastName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
name={'accountParams.height'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.height'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'accountParams.weight'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.weight'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<FormField
name={'phone'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.phone'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'email'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.email'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
<Trans i18nKey="account:myHabits" />
</h2>
<FormField
name="accountParams.isSmoker"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.smoking'} />
</FormLabel>
<FormControl>
<Select
value={
field.value === true
? 'yes'
: field.value === false
? 'no'
: 'preferNotToAnswer'
}
onValueChange={(value) => {
if (value === 'yes') {
field.onChange(true);
} else if (value === 'no') {
field.onChange(false);
} else {
field.onChange(null);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">
<Trans i18nKey="common:yes" />
</SelectItem>
<SelectItem value="no">
<Trans i18nKey="common:no" />
</SelectItem>
<SelectItem value="preferNotToAnswer">
<Trans i18nKey="common:preferNotToAnswer" />
</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-36">
<Trans i18nKey="account:updateProfileSubmitLabel" />
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { StoreCart } from '@medusajs/types';
import { Cross, Menu, Shield, ShoppingCart } from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
import { pathsConfig } from '@kit/shared/config';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import { UserWorkspace } from '../../_lib/server/load-user-workspace';
import { routes } from './settings-sidebar';
export function SettingsMobileNavigation(props: {
workspace: UserWorkspace;
cart: StoreCart | null;
}) {
const user = props.workspace.user;
const signOut = useSignOut();
const { data: personalAccountData } = usePersonalAccountData(user.id);
const Links = [
{
children: [{ path: pathsConfig.app.home, label: 'common:routes.home' }],
},
]
.concat(routes)
.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
const hasTotpFactor = useMemo(() => {
const factors = user?.factors ?? [];
return factors.some(
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
);
}, [user?.factors]);
const isSuperAdmin = useMemo(() => {
const hasAdminRole =
personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin;
return hasAdminRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
const isDoctor = useMemo(() => {
const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={props.cart && hasCartItems}>
<DropdownMenuGroup>
<DropdownLink
path={pathsConfig.app.cart}
label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartQuantityTotal }}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.admin}
>
<Shield className={'h-5'} />
<span>Super Admin</span>
</Link>
</DropdownMenuItem>
</If>
<If condition={isDoctor}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
'flex h-full cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.doctor}
>
<Cross className={'h-5'} />
<span>
<Trans i18nKey="common:doctor" />
</span>
</Link>
</DropdownMenuItem>
</If>
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,22 @@
import { Separator } from "@kit/ui/separator";
import { Trans } from "@kit/ui/trans";
export default function SettingsSectionHeader({
titleKey,
descriptionKey,
}: {
titleKey: string;
descriptionKey: string;
}) {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-gray-900">
<Trans i18nKey={titleKey} />
</h1>
<p className="text-gray-600">
<Trans i18nKey={descriptionKey} />
</p>
<Separator />
</div>
);
}

View File

@@ -0,0 +1,55 @@
import z from 'zod';
import { pathsConfig } from '@kit/shared/config';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { PageHeader } from '@kit/ui/page';
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarNavigation,
} from '@kit/ui/shadcn-sidebar';
import { Trans } from '@kit/ui/trans';
export const routes = [
{
children: [
{
label: 'common:routes.profile',
path: pathsConfig.app.personalAccountSettings,
end: true,
},
{
label: 'common:routes.preferences',
path: pathsConfig.app.personalAccountPreferences,
end: true,
},
{
label: 'common:routes.security',
path: pathsConfig.app.personalAccountSecurity,
end: true,
},
],
},
] satisfies z.infer<typeof NavigationConfigSchema>['routes'];
export function SettingsSidebar() {
return (
<Sidebar>
<SidebarHeader className="mt-16 h-24 w-[95vw] max-w-screen justify-center border-b bg-white pt-2">
<PageHeader
title={<Trans i18nKey="account:accountTabLabel" />}
description={<Trans i18nKey={'account:accountTabDescription'} />}
/>
</SidebarHeader>
<SidebarContent className="w-auto">
<SidebarNavigation
config={{ style: 'custom', sidebarCollapsedStyle: 'none', routes }}
/>
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const accountPreferencesSchema = z.object({
preferredLanguage: z.enum(['et', 'en', 'ru']).optional().nullable(),
isConsentToAnonymizedCompanyStatistics: z.boolean().optional(),
});
export type AccountPreferences = z.infer<typeof accountPreferencesSchema>;

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
export const accountSettingsSchema = z.object({
firstName: z
.string()
.min(1, { error: 'error:tooShort' })
.max(200, { error: 'error:tooLong' }),
lastName: z
.string()
.min(1, { error: 'error:tooShort' })
.max(200, { error: 'error:tooLong' }),
email: z.email({ error: 'error:invalidEmail' }).nullable(),
phone: z.e164({ error: 'error:invalidPhone' }),
accountParams: z.object({
height: z.coerce.number({ error: 'error:invalidNumber' }),
weight: z.coerce.number({ error: 'error:invalidNumber' }),
isSmoker: z.boolean().optional().nullable(),
}),
});
export type AccountSettings = z.infer<typeof accountSettingsSchema>;

View File

@@ -0,0 +1,66 @@
'use server';
import z from 'zod';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import {
updatePersonalAccount,
updatePersonalAccountPreferences,
} from '~/lib/services/account.service';
import {
AccountPreferences,
accountPreferencesSchema,
} from '../account-preferences.schema';
import {
AccountSettings,
accountSettingsSchema,
} from '../account-settings.schema';
export const updatePersonalAccountAction = enhanceAction(
async ({ accountId, data }: { accountId: string; data: AccountSettings }) => {
const logger = await getLogger();
try {
logger.info({ accountId }, 'Updating account');
await updatePersonalAccount(accountId, data);
logger.info({ accountId }, 'Successfully updated account');
return { success: true };
} catch (e) {
logger.error('Failed to update account', JSON.stringify(e));
return { success: false };
}
},
{
schema: z.object({ accountId: z.uuid(), data: accountSettingsSchema }),
},
);
export const updatePersonalAccountPreferencesAction = enhanceAction(
async ({
accountId,
data,
}: {
accountId: string;
data: AccountPreferences;
}) => {
const logger = await getLogger();
try {
logger.info({ accountId }, 'Updating account preferences');
await updatePersonalAccountPreferences(accountId, data);
logger.info({ accountId }, 'Successfully updated account preferences');
return { success: true };
} catch (e) {
logger.error('Failed to update account preferences', JSON.stringify(e));
return { success: false };
}
},
{
schema: z.object({ accountId: z.uuid(), data: accountPreferencesSchema }),
},
);

View File

@@ -1,23 +1,69 @@
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; import { use } from 'react';
import { Trans } from '@kit/ui/trans';
import { retrieveCart } from '@lib/data/cart';
import { StoreCart } from '@medusajs/types';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { AppLogo } from '@kit/shared/components/app-logo';
import { pathsConfig } from '@kit/shared/config';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { SettingsSidebar } from './_components/settings-sidebar';
// home imports
import { HomeMenuNavigation } from '../_components/home-menu-navigation';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { SettingsMobileNavigation } from './_components/settings-navigation';
// local imports function UserSettingsLayout({ children }: React.PropsWithChildren) {
import { HomeLayoutPageHeader } from '../_components/home-page-header'; return <HeaderLayout>{children}</HeaderLayout>;
function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'account:routes.settings'} />}
description={<AppBreadcrumbs />}
/>
{props.children}
</>
);
} }
export default withI18n(UserSettingsLayout); export default withI18n(UserSettingsLayout);
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart());
return (
<UserWorkspaceContextProvider value={workspace}>
<Page style={'header'}>
<PageNavigation>
<HomeMenuNavigation workspace={workspace} cart={cart} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} cart={cart} />
</PageMobileNavigation>
<SidebarProvider defaultOpen>
<Page style={'sidebar'}>
<PageNavigation>
<SettingsSidebar />
</PageNavigation>
<div className="md:mt-28 min-w-full min-h-full">{children}</div>
</Page>
</SidebarProvider>
</Page>
</UserWorkspaceContextProvider>
);
}
function MobileNavigation({
workspace,
cart,
}: {
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
cart: StoreCart | null;
}) {
return (
<>
<AppLogo href={pathsConfig.app.home} />
<SettingsMobileNavigation workspace={workspace} cart={cart} />
</>
);
}

View File

@@ -1,28 +1,11 @@
import { use } from 'react';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
import {
authConfig,
featureFlagsConfig,
pathsConfig,
} from '@kit/shared/config';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
const features = { import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion, import AccountSettingsForm from './_components/account-settings-form';
enablePasswordUpdate: authConfig.providers.password, import SettingsSectionHeader from './_components/settings-section-header';
};
const callbackPath = pathsConfig.auth.callback;
const accountHomePath = pathsConfig.app.accountHome;
const paths = {
callback: callbackPath + `?next=${accountHomePath}`,
};
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();
@@ -33,17 +16,18 @@ export const generateMetadata = async () => {
}; };
}; };
function PersonalAccountSettingsPage() { async function PersonalAccountSettingsPage() {
const user = use(requireUserInServerComponent()); const account = await loadCurrentUserAccount();
return ( return (
<PageBody> <PageBody>
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}> <div className="mx-auto w-full bg-white p-6">
<PersonalAccountSettingsContainer <div className="space-y-6">
userId={user.id} <SettingsSectionHeader
features={features} titleKey="account:accountTabLabel"
paths={paths} descriptionKey="account:accountTabDescription"
/> />
<AccountSettingsForm account={account} />
</div>
</div> </div>
</PageBody> </PageBody>
); );

View File

@@ -0,0 +1,24 @@
import { CardTitle } from '@kit/ui/card';
import { LanguageSelector } from '@kit/ui/language-selector';
import { Trans } from '@kit/ui/trans';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
import AccountPreferencesForm from '../_components/account-preferences-form';
import SettingsSectionHeader from '../_components/settings-section-header';
export default async function PreferencesPage() {
const account = await loadCurrentUserAccount();
return (
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
<SettingsSectionHeader
titleKey="account:preferencesTabLabel"
descriptionKey="account:preferencesTabDescription"
/>
<AccountPreferencesForm account={account} />
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { MultiFactorAuthFactorsList } from '@kit/accounts/components';
import SettingsSectionHeader from '../_components/settings-section-header';
export default function SecuritySettingsPage() {
return (
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
<SettingsSectionHeader
titleKey="account:securityTabLabel"
descriptionKey="account:securityTabDescription"
/>
<MultiFactorAuthFactorsList />
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import Link from 'next/link'; import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react'; import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import { Home, Menu } from 'lucide-react';
import { AccountSelector } from '@kit/accounts/account-selector'; import { AccountSelector } from '@kit/accounts/account-selector';
import { import {
@@ -92,47 +93,7 @@ export const TeamAccountLayoutMobileNavigation = (
); );
}; };
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem asChild>
<Link
href={props.path}
className={'flex h-12 w-full items-center gap-x-3 px-3'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
</DropdownMenuItem>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onClick={props.onSignOut}
>
<LogOut className={'h-4'} />
<span>
<Trans i18nKey={'common:signOut'} />
</span>
</DropdownMenuItem>
);
}
function TeamAccountsModal(props: { function TeamAccountsModal(props: {
accounts: Accounts; accounts: Accounts;

View File

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

View File

@@ -41,6 +41,7 @@ export const defaultI18nNamespaces = [
'orders', 'orders',
'analysis-results', 'analysis-results',
'doctor', 'doctor',
'error',
]; ];
/** /**

View File

@@ -1,8 +1,14 @@
import type { Tables } from '@/packages/supabase/src/database.types'; import type { Tables } from '@/packages/supabase/src/database.types';
import { AccountWithParams } from '@kit/accounts/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountSettings } from '~/home/(user)/settings/_lib/account-settings.schema';
import { AccountPreferences } from '../../app/home/(user)/settings/_lib/account-preferences.schema';
import { updateCustomer } from '../../packages/features/medusa-storefront/src/lib/data';
type Account = Tables<{ schema: 'medreport' }, 'accounts'>; type Account = Tables<{ schema: 'medreport' }, 'accounts'>;
type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'>; type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'>;
@@ -61,7 +67,9 @@ export async function getDoctorAccounts() {
} }
export async function getAssignedDoctorAccount(analysisOrderId: number) { export async function getAssignedDoctorAccount(analysisOrderId: number) {
const { data: doctorUser } = await getSupabaseServerAdminClient() const supabase = getSupabaseServerAdminClient();
const { data: doctorUser } = await supabase
.schema('medreport') .schema('medreport')
.from('doctor_analysis_feedback') .from('doctor_analysis_feedback')
.select('doctor_user_id') .select('doctor_user_id')
@@ -73,7 +81,7 @@ export async function getAssignedDoctorAccount(analysisOrderId: number) {
return null; return null;
} }
const { data } = await getSupabaseServerAdminClient() const { data } = await supabase
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
.select('email') .select('email')
@@ -81,3 +89,58 @@ export async function getAssignedDoctorAccount(analysisOrderId: number) {
return { email: data?.[0]?.email }; return { email: data?.[0]?.email };
} }
export async function updatePersonalAccount(
accountId: string,
account: AccountSettings,
) {
const supabase = getSupabaseServerClient();
return Promise.all([
supabase
.schema('medreport')
.from('accounts')
.update({
name: account.firstName,
last_name: account.lastName,
email: account.email,
phone: account.phone,
})
.eq('id', accountId)
.throwOnError(),
supabase
.schema('medreport')
.from('account_params')
.upsert(
{
height: account.accountParams.height,
weight: account.accountParams.weight,
is_smoker: account.accountParams.isSmoker,
},
{ onConflict: 'account_id' },
)
.throwOnError(),
updateCustomer({
first_name: account.firstName,
last_name: account.lastName,
phone: account.phone,
}),
]);
}
export async function updatePersonalAccountPreferences(
accountId: string,
preferences: AccountPreferences,
) {
const supabase = getSupabaseServerClient();
return supabase
.schema('medreport')
.from('accounts')
.update({
preferred_locale: preferences.preferredLanguage,
has_consent_anonymized_company_statistics:
preferences.isConsentToAnonymizedCompanyStatistics,
})
.eq('id', accountId)
.throwOnError();
}

View File

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

View File

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

View File

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

View File

@@ -16,12 +16,12 @@ const env = () =>
.object({ .object({
medusaBackendPublicUrl: z medusaBackendPublicUrl: z
.string({ .string({
required_error: 'MEDUSA_BACKEND_PUBLIC_URL is required', error: 'MEDUSA_BACKEND_PUBLIC_URL is required',
}) })
.min(1), .min(1),
siteUrl: z siteUrl: z
.string({ .string({
required_error: 'NEXT_PUBLIC_SITE_URL is required', error: 'NEXT_PUBLIC_SITE_URL is required',
}) })
.min(1), .min(1),
}) })

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,14 @@ import { z } from 'zod';
export const companyOfferSchema = z.object({ export const companyOfferSchema = z.object({
companyName: z.string({ companyName: z.string({
required_error: 'Company name is required', error: 'Company name is required',
}), }),
contactPerson: z.string({ contactPerson: z.string({
required_error: 'Contact person is required', error: 'Contact person is required',
}), }),
email: z email: z
.string({ .email({
required_error: 'Email is required', error: 'Invalid email',
}) }),
.email('Invalid email'),
phone: z.string().optional(), phone: z.string().optional(),
}); });

View File

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

View File

@@ -33,13 +33,13 @@
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@kit/accounts": "workspace:*", "@kit/accounts": "workspace:*",
"@kit/admin": "workspace:*", "@kit/admin": "workspace:*",
"@kit/doctor": "workspace:*",
"@kit/analytics": "workspace:*", "@kit/analytics": "workspace:*",
"@kit/auth": "workspace:*", "@kit/auth": "workspace:*",
"@kit/billing": "workspace:*", "@kit/billing": "workspace:*",
"@kit/billing-gateway": "workspace:*", "@kit/billing-gateway": "workspace:*",
"@kit/cms": "workspace:*", "@kit/cms": "workspace:*",
"@kit/database-webhooks": "workspace:*", "@kit/database-webhooks": "workspace:*",
"@kit/doctor": "workspace:*",
"@kit/email-templates": "workspace:*", "@kit/email-templates": "workspace:*",
"@kit/i18n": "workspace:*", "@kit/i18n": "workspace:*",
"@kit/mailers": "workspace:*", "@kit/mailers": "workspace:*",
@@ -69,6 +69,7 @@
"fast-xml-parser": "^5.2.5", "fast-xml-parser": "^5.2.5",
"isikukood": "3.1.7", "isikukood": "3.1.7",
"jsonwebtoken": "9.0.2", "jsonwebtoken": "9.0.2",
"libphonenumber-js": "^1.12.15",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.510.0", "lucide-react": "^0.510.0",
"next": "15.3.2", "next": "15.3.2",
@@ -82,7 +83,7 @@
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"zod": "^3.25.67" "zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",

View File

@@ -21,39 +21,25 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
export const LineItemSchema = z export const LineItemSchema = z
.object({ .object({
id: z id: z
.string({ .string()
description: .describe('Unique identifier for the line item. Defined by the Provider.')
'Unique identifier for the line item. Defined by the Provider.',
})
.min(1), .min(1),
name: z name: z
.string({ .string().describe('Name of the line item. Displayed to the user.')
description: 'Name of the line item. Displayed to the user.',
})
.min(1), .min(1),
description: z description: z
.string({ .string().describe('Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
description: ' from the line item. This is useful if you want to provide a more detailed description to the user.')
'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
' from the line item. This is useful if you want to provide a more detailed description to the user.',
})
.optional(), .optional(),
cost: z cost: z
.number({ .number().describe('Cost of the line item. Displayed to the user.')
description: 'Cost of the line item. Displayed to the user.',
})
.min(0), .min(0),
type: LineItemTypeSchema, type: LineItemTypeSchema,
unit: z unit: z
.string({ .string().describe('Unit of the line item. Displayed to the user. Example "seat" or "GB"')
description:
'Unit of the line item. Displayed to the user. Example "seat" or "GB"',
})
.optional(), .optional(),
setupFee: z setupFee: z
.number({ .number().describe(`Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`)
description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`,
})
.positive() .positive()
.optional(), .optional(),
tiers: z tiers: z
@@ -92,14 +78,10 @@ export const LineItemSchema = z
export const PlanSchema = z export const PlanSchema = z
.object({ .object({
id: z id: z
.string({ .string().describe('Unique identifier for the plan. Defined by yourself.')
description: 'Unique identifier for the plan. Defined by yourself.',
})
.min(1), .min(1),
name: z name: z
.string({ .string().describe('Name of the plan. Displayed to the user.')
description: 'Name of the plan. Displayed to the user.',
})
.min(1), .min(1),
interval: BillingIntervalSchema.optional(), interval: BillingIntervalSchema.optional(),
custom: z.boolean().default(false).optional(), custom: z.boolean().default(false).optional(),
@@ -124,10 +106,7 @@ export const PlanSchema = z
}, },
), ),
trialDays: z trialDays: z
.number({ .number().describe('Number of days for the trial period. Leave empty for no trial.')
description:
'Number of days for the trial period. Leave empty for no trial.',
})
.positive() .positive()
.optional(), .optional(),
paymentType: PaymentTypeSchema, paymentType: PaymentTypeSchema,
@@ -209,54 +188,34 @@ export const PlanSchema = z
const ProductSchema = z const ProductSchema = z
.object({ .object({
id: z id: z
.string({ .string().describe('Unique identifier for the product. Defined by th Provider.')
description:
'Unique identifier for the product. Defined by th Provider.',
})
.min(1), .min(1),
name: z name: z
.string({ .string().describe('Name of the product. Displayed to the user.')
description: 'Name of the product. Displayed to the user.',
})
.min(1), .min(1),
description: z description: z
.string({ .string().describe('Description of the product. Displayed to the user.')
description: 'Description of the product. Displayed to the user.',
})
.min(1), .min(1),
currency: z currency: z
.string({ .string().describe('Currency code for the product. Displayed to the user.')
description: 'Currency code for the product. Displayed to the user.',
})
.min(3) .min(3)
.max(3), .max(3),
badge: z badge: z
.string({ .string().describe('Badge for the product. Displayed to the user. Example: "Popular"')
description:
'Badge for the product. Displayed to the user. Example: "Popular"',
})
.optional(), .optional(),
features: z features: z
.array( .array(
z.string({ z.string(),
description: 'Features of the product. Displayed to the user.', ).describe('Features of the product. Displayed to the user.')
}),
)
.nonempty(), .nonempty(),
enableDiscountField: z enableDiscountField: z
.boolean({ .boolean().describe('Enable discount field for the product in the checkout.')
description: 'Enable discount field for the product in the checkout.',
})
.optional(), .optional(),
highlighted: z highlighted: z
.boolean({ .boolean().describe('Highlight this product. Displayed to the user.')
description: 'Highlight this product. Displayed to the user.',
})
.optional(), .optional(),
hidden: z hidden: z
.boolean({ .boolean().describe('Hide this product from being displayed to users.')
description: 'Hide this product from being displayed to users.',
})
.optional(), .optional(),
plans: z.array(PlanSchema), plans: z.array(PlanSchema),
}) })

View File

@@ -1,14 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
export const ReportBillingUsageSchema = z.object({ export const ReportBillingUsageSchema = z.object({
id: z.string({ id: z.string().describe('The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.'),
description:
'The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.',
}),
eventName: z eventName: z
.string({ .string()
description: 'The name of the event that triggered the usage', .describe('The name of the event that triggered the usage')
})
.optional(), .optional(),
usage: z.object({ usage: z.object({
quantity: z.number(), quantity: z.number(),

View File

@@ -3,8 +3,8 @@ import { z } from 'zod';
export const UpdateHealthBenefitSchema = z.object({ export const UpdateHealthBenefitSchema = z.object({
occurance: z occurance: z
.string({ .string({
required_error: 'Occurance is required', error: 'Occurance is required',
}) })
.nonempty(), .nonempty(),
amount: z.number({ required_error: 'Amount is required' }), amount: z.number({ error: 'Amount is required' }),
}); });

View File

@@ -4,12 +4,12 @@ export const MontonioServerEnvSchema = z
.object({ .object({
secretKey: z secretKey: z
.string({ .string({
required_error: `Please provide the variable MONTONIO_SECRET_KEY`, error: `Please provide the variable MONTONIO_SECRET_KEY`,
}) })
.min(1), .min(1),
apiUrl: z apiUrl: z
.string({ .string({
required_error: `Please provide the variable MONTONIO_API_URL`, error: `Please provide the variable MONTONIO_API_URL`,
}) })
.min(1), .min(1),
}); });

View File

@@ -4,12 +4,12 @@ export const StripeServerEnvSchema = z
.object({ .object({
secretKey: z secretKey: z
.string({ .string({
required_error: `Please provide the variable STRIPE_SECRET_KEY`, error: `Please provide the variable STRIPE_SECRET_KEY`,
}) })
.min(1), .min(1),
webhooksSecret: z webhooksSecret: z
.string({ .string({
required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`, error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
}) })
.min(1), .min(1),
}) })

View File

@@ -4,9 +4,9 @@ import { DatabaseWebhookVerifierService } from './database-webhook-verifier.serv
const webhooksSecret = z const webhooksSecret = z
.string({ .string({
description: `The secret used to verify the webhook signature`, error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
required_error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
}) })
.describe(`The secret used to verify the webhook signature`,)
.min(1) .min(1)
.parse(process.env.SUPABASE_DB_WEBHOOK_SECRET); .parse(process.env.SUPABASE_DB_WEBHOOK_SECRET);

View File

@@ -1 +1,3 @@
export * from './user-workspace-context'; export * from './user-workspace-context';
export * from './personal-account-settings/mfa/multi-factor-auth-list'
export * from './personal-account-settings/mfa/multi-factor-auth-setup-dialog'

View File

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

View File

@@ -1,208 +0,0 @@
'use client';
import { useTranslation } from 'react-i18next';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { LanguageSelector } from '@kit/ui/language-selector';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { usePersonalAccountData } from '../../hooks/use-personal-account-data';
import { AccountDangerZone } from './account-danger-zone';
import ConsentToggle from './consent/consent-toggle';
import { UpdateEmailFormContainer } from './email/update-email-form-container';
import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list';
import { UpdatePasswordFormContainer } from './password/update-password-container';
import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container';
import { UpdateAccountImageContainer } from './update-account-image-container';
export function PersonalAccountSettingsContainer(
props: React.PropsWithChildren<{
userId: string;
features: {
enableAccountDeletion: boolean;
enablePasswordUpdate: boolean;
};
paths: {
callback: string;
};
}>,
) {
const supportsLanguageSelection = useSupportMultiLanguage();
const user = usePersonalAccountData(props.userId);
if (!user.data || user.isPending) {
return <LoadingOverlay fullPage />;
}
return (
<div className={'flex w-full flex-col space-y-4 pb-32'}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:accountImage'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:accountImageDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateAccountImageContainer
user={{
pictureUrl: user.data.picture_url,
id: user.data.id,
}}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:name'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:nameDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateAccountDetailsFormContainer user={user.data} />
</CardContent>
</Card>
<If condition={supportsLanguageSelection}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:language'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:languageDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<LanguageSelector />
</CardContent>
</Card>
</If>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:updateEmailCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:updateEmailCardDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateEmailFormContainer callbackPath={props.paths.callback} />
</CardContent>
</Card>
<If condition={props.features.enablePasswordUpdate}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:updatePasswordCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:updatePasswordCardDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdatePasswordFormContainer callbackPath={props.paths.callback} />
</CardContent>
</Card>
</If>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:multiFactorAuth'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<MultiFactorAuthFactorsList userId={props.userId} />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex justify-between">
<div>
<p className="font-medium">
<Trans
i18nKey={'account:consentToAnonymizedCompanyData.label'}
/>
</p>
<CardDescription>
<Trans
i18nKey={'account:consentToAnonymizedCompanyData.description'}
/>
</CardDescription>
</div>
<ConsentToggle
userId={props.userId}
initialState={
!!user.data.has_consent_anonymized_company_statistics
}
/>
</div>
</CardHeader>
</Card>
<If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:dangerZoneDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<AccountDangerZone />
</CardContent>
</Card>
</If>
</div>
);
}
function useSupportMultiLanguage() {
const { i18n } = useTranslation();
const langs = (i18n?.options?.supportedLngs as string[]) ?? [];
const supportedLangs = langs.filter((lang) => lang !== 'cimode');
return supportedLangs.length > 1;
}

View File

@@ -1,46 +0,0 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { Switch } from '@kit/ui/switch';
import { Trans } from '@kit/ui/trans';
import { useRevalidatePersonalAccountDataQuery } from '../../../hooks/use-personal-account-data';
import { useUpdateAccountData } from '../../../hooks/use-update-account';
// This is temporary. When the profile views are ready, all account values included in the form will be updated together on form submit.
export default function ConsentToggle({
userId,
initialState,
}: {
userId: string;
initialState: boolean;
}) {
const [isConsent, setIsConsent] = useState(initialState);
const updateAccountMutation = useUpdateAccountData(userId);
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const updateConsent = (consent: boolean) => {
const promise = updateAccountMutation
.mutateAsync({
has_consent_anonymized_company_statistics: consent,
})
.then(() => {
revalidateUserDataQuery(userId);
});
return toast.promise(() => promise, {
success: <Trans i18nKey={'account:updateConsentSuccess'} />,
error: <Trans i18nKey={'account:updateConsentError'} />,
loading: <Trans i18nKey={'account:updateConsentLoading'} />,
});
};
return (
<Switch
checked={isConsent}
onCheckedChange={setIsConsent}
onClick={() => updateConsent(!isConsent)}
disabled={updateAccountMutation.isPending}
/>
);
}

View File

@@ -12,6 +12,7 @@ import { toast } from 'sonner';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors'; import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useUser } from '@kit/supabase/hooks/use-user';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key'; import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
@@ -46,13 +47,18 @@ import { Trans } from '@kit/ui/trans';
import { MultiFactorAuthSetupDialog } from './multi-factor-auth-setup-dialog'; import { MultiFactorAuthSetupDialog } from './multi-factor-auth-setup-dialog';
export function MultiFactorAuthFactorsList(props: { userId: string }) { export function MultiFactorAuthFactorsList() {
const { data: user } = useUser();
if (!user?.id) {
return null;
}
return ( return (
<div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-4'}>
<FactorsTableContainer userId={props.userId} /> <FactorsTableContainer userId={user?.id} />
<div> <div>
<MultiFactorAuthSetupDialog userId={props.userId} /> <MultiFactorAuthSetupDialog userId={user?.id} />
</div> </div>
</div> </div>
); );

View File

@@ -1,42 +0,0 @@
'use client';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert } from '@kit/ui/alert';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { UpdatePasswordForm } from './update-password-form';
export function UpdatePasswordFormContainer(
props: React.PropsWithChildren<{
callbackPath: string;
}>,
) {
const { data: user, isPending } = useUser();
if (isPending) {
return <LoadingOverlay fullPage={false} />;
}
if (!user) {
return null;
}
const canUpdatePassword = user.identities?.some(
(item) => item.provider === `email`,
);
if (!canUpdatePassword) {
return <WarnCannotUpdatePasswordAlert />;
}
return <UpdatePasswordForm callbackPath={props.callbackPath} user={user} />;
}
function WarnCannotUpdatePasswordAlert() {
return (
<Alert variant={'warning'}>
<Trans i18nKey={'account:cannotUpdatePassword'} />
</Alert>
);
}

View File

@@ -1,206 +0,0 @@
'use client';
import { useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans';
import { PasswordUpdateSchema } from '../../../schema/update-password.schema';
export const UpdatePasswordForm = ({
user,
callbackPath,
}: {
user: User;
callbackPath: string;
}) => {
const { t } = useTranslation('account');
const updateUserMutation = useUpdateUser();
const [needsReauthentication, setNeedsReauthentication] = useState(false);
const updatePasswordFromCredential = (password: string) => {
const redirectTo = [window.location.origin, callbackPath].join('');
const promise = updateUserMutation
.mutateAsync({ password, redirectTo })
.catch((error) => {
if (
typeof error === 'string' &&
error?.includes('Password update requires reauthentication')
) {
setNeedsReauthentication(true);
} else {
throw error;
}
});
toast.promise(() => promise, {
success: t(`updatePasswordSuccess`),
error: t(`updatePasswordError`),
loading: t(`updatePasswordLoading`),
});
};
const updatePasswordCallback = async ({
newPassword,
}: {
newPassword: string;
}) => {
const email = user.email;
// if the user does not have an email assigned, it's possible they
// don't have an email/password factor linked, and the UI is out of sync
if (!email) {
return Promise.reject(t(`cannotUpdatePassword`));
}
updatePasswordFromCredential(newPassword);
};
const form = useForm({
resolver: zodResolver(
PasswordUpdateSchema.withTranslation(t('passwordNotMatching')),
),
defaultValues: {
newPassword: '',
repeatPassword: '',
},
});
return (
<Form {...form}>
<form
data-test={'account-password-form'}
onSubmit={form.handleSubmit(updatePasswordCallback)}
>
<div className={'flex flex-col space-y-4'}>
<If condition={updateUserMutation.data}>
<SuccessAlert />
</If>
<If condition={needsReauthentication}>
<NeedsReauthenticationAlert />
</If>
<FormField
name={'newPassword'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Label>
<Trans i18nKey={'account:newPassword'} />
</Label>
</FormLabel>
<FormControl>
<Input
data-test={'account-password-form-password-input'}
autoComplete={'new-password'}
required
type={'password'}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'repeatPassword'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Label>
<Trans i18nKey={'account:repeatPassword'} />
</Label>
</FormLabel>
<FormControl>
<Input
data-test={'account-password-form-repeat-password-input'}
required
type={'password'}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'account:repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<div>
<Button disabled={updateUserMutation.isPending}>
<Trans i18nKey={'account:updatePasswordSubmitLabel'} />
</Button>
</div>
</div>
</form>
</Form>
);
};
function SuccessAlert() {
return (
<Alert variant={'success'}>
<Check className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updatePasswordSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
</AlertDescription>
</Alert>
);
}
function NeedsReauthenticationAlert() {
return (
<Alert variant={'warning'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:needsReauthentication'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:needsReauthenticationDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -1,23 +0,0 @@
'use client';
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
import { UpdateAccountDetailsForm } from './update-account-details-form';
export function UpdateAccountDetailsFormContainer({
user,
}: {
user: {
name: string | null;
id: string;
};
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
return (
<UpdateAccountDetailsForm
displayName={user.name ?? ''}
userId={user.id}
onUpdate={() => revalidateUserDataQuery(user.id)}
/>
);
}

View File

@@ -1,98 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { useUpdateAccountData } from '../../hooks/use-update-account';
import { AccountDetailsSchema } from '../../schema/account-details.schema';
type UpdateUserDataParams =
Database['medreport']['Tables']['accounts']['Update'];
export function UpdateAccountDetailsForm({
displayName,
onUpdate,
userId,
}: {
displayName: string;
userId: string;
onUpdate: (user: Partial<UpdateUserDataParams>) => void;
}) {
const updateAccountMutation = useUpdateAccountData(userId);
const { t } = useTranslation('account');
const form = useForm({
resolver: zodResolver(AccountDetailsSchema),
defaultValues: {
displayName,
},
});
const onSubmit = ({ displayName }: { displayName: string }) => {
const data = { name: displayName };
const promise = updateAccountMutation.mutateAsync(data).then(() => {
onUpdate(data);
});
return toast.promise(() => promise, {
success: t(`updateProfileSuccess`),
error: t(`updateProfileError`),
loading: t(`updateProfileLoading`),
});
};
return (
<div className={'flex flex-col space-y-8'}>
<Form {...form}>
<form
data-test={'update-account-name-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name={'displayName'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:name'} />
</FormLabel>
<FormControl>
<Input
data-test={'account-display-name'}
minLength={2}
placeholder={''}
maxLength={100}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account:updateProfileSubmitLabel'} />
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -1,168 +0,0 @@
'use client';
import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Database } from '@kit/supabase/database';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { ImageUploader } from '@kit/ui/image-uploader';
import { Trans } from '@kit/ui/trans';
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
const AVATARS_BUCKET = 'account_image';
export function UpdateAccountImageContainer({
user,
}: {
user: {
pictureUrl: string | null;
id: string;
};
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
return (
<UploadProfileAvatarForm
pictureUrl={user.pictureUrl ?? null}
userId={user.id}
onAvatarUpdated={() => revalidateUserDataQuery(user.id)}
/>
);
}
function UploadProfileAvatarForm(props: {
pictureUrl: string | null;
userId: string;
onAvatarUpdated: () => void;
}) {
const client = useSupabase();
const { t } = useTranslation('account');
const createToaster = useCallback(
(promise: () => Promise<unknown>) => {
return toast.promise(promise, {
success: t(`updateProfileSuccess`),
error: t(`updateProfileError`),
loading: t(`updateProfileLoading`),
});
},
[t],
);
const onValueChange = useCallback(
(file: File | null) => {
const removeExistingStorageFile = () => {
if (props.pictureUrl) {
return (
deleteProfilePhoto(client, props.pictureUrl) ?? Promise.resolve()
);
}
return Promise.resolve();
};
if (file) {
const promise = () =>
removeExistingStorageFile().then(() =>
uploadUserProfilePhoto(client, file, props.userId)
.then((pictureUrl) => {
return client
.schema('medreport')
.from('accounts')
.update({
picture_url: pictureUrl,
})
.eq('id', props.userId)
.throwOnError();
})
.then(() => {
props.onAvatarUpdated();
}),
);
createToaster(promise);
} else {
const promise = () =>
removeExistingStorageFile()
.then(() => {
return client
.schema('medreport')
.from('accounts')
.update({
picture_url: null,
})
.eq('id', props.userId)
.throwOnError();
})
.then(() => {
props.onAvatarUpdated();
});
createToaster(promise);
}
},
[client, createToaster, props],
);
return (
<ImageUploader value={props.pictureUrl} onValueChange={onValueChange}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm'}>
<Trans i18nKey={'account:profilePictureHeading'} />
</span>
<span className={'text-xs'}>
<Trans i18nKey={'account:profilePictureSubheading'} />
</span>
</div>
</ImageUploader>
);
}
function deleteProfilePhoto(client: SupabaseClient<Database>, url: string) {
const bucket = client.storage.from(AVATARS_BUCKET);
const fileName = url.split('/').pop()?.split('?')[0];
if (!fileName) {
return;
}
return bucket.remove([fileName]);
}
async function uploadUserProfilePhoto(
client: SupabaseClient<Database>,
photoFile: File,
userId: string,
) {
const bytes = await photoFile.arrayBuffer();
const bucket = client.storage.from(AVATARS_BUCKET);
const extension = photoFile.name.split('.').pop();
const fileName = await getAvatarFileName(userId, extension);
const result = await bucket.upload(fileName, bytes);
if (!result.error) {
return bucket.getPublicUrl(fileName).data.publicUrl;
}
throw result.error;
}
async function getAvatarFileName(
userId: string,
extension: string | undefined,
) {
const { nanoid } = await import('nanoid');
// we add a version to the URL to ensure
// the browser always fetches the latest image
const uniqueId = nanoid(16);
return `${userId}.${extension}?v=${uniqueId}`;
}

View File

@@ -2,19 +2,19 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
AnalysisResultDetails,
UserAnalysis,
UserAnalysisResponse,
} from '../types/accounts';
export type AccountWithParams = export type AccountWithParams =
Database['medreport']['Tables']['accounts']['Row'] & { Database['medreport']['Tables']['accounts']['Row'] & {
account_params: accountParams:
| Pick< | (Pick<
Database['medreport']['Tables']['account_params']['Row'], Database['medreport']['Tables']['account_params']['Row'],
'weight' | 'height' 'weight' | 'height'
>[] > & {
isSmoker:
| Database['medreport']['Tables']['account_params']['Row']['is_smoker']
| null;
})
| null; | null;
}; };
@@ -35,7 +35,9 @@ class AccountsApi {
const { data, error } = await this.client const { data, error } = await this.client
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
.select('*, account_params: account_params (weight, height)') .select(
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
)
.eq('id', id) .eq('id', id)
.single(); .single();
@@ -46,6 +48,41 @@ class AccountsApi {
return data; return data;
} }
/**
* @name getPersonalAccountByUserId
* @description Get the personal account data for the given user ID.
* @param userId
*/
async getPersonalAccountByUserId(userId: string): Promise<AccountWithParams> {
const { data, error } = await this.client
.schema('medreport')
.from('accounts')
.select(
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
)
.eq('primary_owner_user_id', userId)
.eq('is_personal_account', true)
.single();
if (error) {
throw error;
}
const { personal_code, ...rest } = data;
return {
...rest,
personal_code: (() => {
if (!personal_code) {
return null;
}
if (personal_code.toLowerCase().startsWith('ee')) {
return personal_code.substring(2);
}
return personal_code;
})(),
};
}
/** /**
* @name getAccountWorkspace * @name getAccountWorkspace
* @description Get the account workspace data. * @description Get the account workspace data.

View File

@@ -32,9 +32,8 @@ const SPECIAL_CHARACTERS_REGEX = /[!@#$%^&*()+=[\]{};':"\\|,.<>/?]/;
* @name CompanyNameSchema * @name CompanyNameSchema
*/ */
export const CompanyNameSchema = z export const CompanyNameSchema = z
.string({ .string()
description: 'The name of the company account', .describe('The name of the company account')
})
.min(2) .min(2)
.max(50) .max(50)
.refine( .refine(

View File

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

View File

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

View File

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

View File

@@ -410,7 +410,7 @@ export async function getAnalysisResultsForDoctor(
.from('accounts') .from('accounts')
.select( .select(
`primary_owner_user_id, id, name, last_name, personal_code, phone, email, preferred_locale, `primary_owner_user_id, id, name, last_name, personal_code, phone, email, preferred_locale,
account_params(height,weight)`, accountParams:account_params(height,weight)`,
) )
.eq('is_personal_account', true) .eq('is_personal_account', true)
.eq('primary_owner_user_id', userId) .eq('primary_owner_user_id', userId)
@@ -472,7 +472,7 @@ export async function getAnalysisResultsForDoctor(
last_name, last_name,
personal_code, personal_code,
phone, phone,
account_params, accountParams,
preferred_locale, preferred_locale,
} = accountWithParams[0]; } = accountWithParams[0];
@@ -513,8 +513,8 @@ export async function getAnalysisResultsForDoctor(
personalCode: personal_code, personalCode: personal_code,
phone, phone,
email, email,
height: account_params?.[0]?.height, height: accountParams?.height,
weight: account_params?.[0]?.weight, weight: accountParams?.weight,
}, },
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,22 +17,22 @@ const env = z
.object({ .object({
invitePath: z invitePath: z
.string({ .string({
required_error: 'The property invitePath is required', error: 'The property invitePath is required',
}) })
.min(1), .min(1),
siteURL: z siteURL: z
.string({ .string({
required_error: 'NEXT_PUBLIC_SITE_URL is required', error: 'NEXT_PUBLIC_SITE_URL is required',
}) })
.min(1), .min(1),
productName: z productName: z
.string({ .string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required', error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
}) })
.min(1), .min(1),
emailSender: z emailSender: z
.string({ .string({
required_error: 'EMAIL_SENDER is required', error: 'EMAIL_SENDER is required',
}) })
.min(1), .min(1),
}) })

View File

@@ -78,7 +78,7 @@ class AccountWebhooksService {
productName: z.string(), productName: z.string(),
fromEmail: z fromEmail: z
.string({ .string({
required_error: 'EMAIL_SENDER is required', error: 'EMAIL_SENDER is required',
}) })
.min(1), .min(1),
}) })

View File

@@ -8,9 +8,9 @@ type Config = z.infer<typeof MailerSchema>;
const RESEND_API_KEY = z const RESEND_API_KEY = z
.string({ .string({
description: 'The API key for the Resend API', error: 'Please provide the API key for the Resend API',
required_error: 'Please provide the API key for the Resend API',
}) })
.describe('The API key for the Resend API')
.parse(process.env.RESEND_API_KEY); .parse(process.env.RESEND_API_KEY);
export function createResendMailer() { export function createResendMailer() {

View File

@@ -4,25 +4,19 @@ import { z } from 'zod';
export const SmtpConfigSchema = z.object({ export const SmtpConfigSchema = z.object({
user: z.string({ user: z.string({
description: error: `Please provide the variable EMAIL_USER`,
'This is the email account to send emails from. This is specific to the email provider.', })
required_error: `Please provide the variable EMAIL_USER`, .describe('This is the email account to send emails from. This is specific to the email provider.'),
}),
pass: z.string({ pass: z.string({
description: 'This is the password for the email account', error: `Please provide the variable EMAIL_PASSWORD`,
required_error: `Please provide the variable EMAIL_PASSWORD`, }).describe('This is the password for the email account'),
}),
host: z.string({ host: z.string({
description: 'This is the SMTP host for the email provider', error: `Please provide the variable EMAIL_HOST`,
required_error: `Please provide the variable EMAIL_HOST`, }).describe('This is the SMTP host for the email provider'),
}),
port: z.number({ port: z.number({
description: error: `Please provide the variable EMAIL_PORT`,
'This is the port for the email provider. Normally 587 or 465.', }).describe('This is the port for the email provider. Normally 587 or 465.'),
required_error: `Please provide the variable EMAIL_PORT`,
}),
secure: z.boolean({ secure: z.boolean({
description: 'This is whether the connection is secure or not', error: `Please provide the variable EMAIL_TLS`,
required_error: `Please provide the variable EMAIL_TLS`, }).describe('This is whether the connection is secure or not'),
}),
}); });

View File

@@ -4,9 +4,9 @@ import { MonitoringService } from '@kit/monitoring-core';
const apiKey = z const apiKey = z
.string({ .string({
required_error: 'NEXT_PUBLIC_BASELIME_KEY is required', error: 'NEXT_PUBLIC_BASELIME_KEY is required',
description: 'The Baseline API key',
}) })
.describe('The Baseline API key')
.parse(process.env.NEXT_PUBLIC_BASELIME_KEY); .parse(process.env.NEXT_PUBLIC_BASELIME_KEY);
export class BaselimeServerMonitoringService implements MonitoringService { export class BaselimeServerMonitoringService implements MonitoringService {

View File

@@ -6,14 +6,14 @@ import { getLogger } from '@kit/shared/logger';
const EMAIL_SENDER = z const EMAIL_SENDER = z
.string({ .string({
required_error: 'EMAIL_SENDER is required', error: 'EMAIL_SENDER is required',
}) })
.min(1) .min(1)
.parse(process.env.EMAIL_SENDER); .parse(process.env.EMAIL_SENDER);
const PRODUCT_NAME = z const PRODUCT_NAME = z
.string({ .string({
required_error: 'PRODUCT_NAME is required', error: 'PRODUCT_NAME is required',
}) })
.min(1) .min(1)
.parse(process.env.NEXT_PUBLIC_PRODUCT_NAME); .parse(process.env.NEXT_PUBLIC_PRODUCT_NAME);

View File

@@ -4,12 +4,10 @@ import type { User } from '@supabase/supabase-js';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { ApplicationRole } from '@kit/accounts/types/accounts'; import { ApplicationRole } from '@kit/accounts/types/accounts';
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { pathsConfig, featureFlagsConfig } from '@kit/shared/config';
const paths = { const paths = {
home: pathsConfig.app.home, home: pathsConfig.app.home,
admin: pathsConfig.app.admin, admin: pathsConfig.app.admin,

View File

@@ -21,6 +21,7 @@ import {
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { ButtonTooltip } from './ui/button-tooltip'; import { ButtonTooltip } from './ui/button-tooltip';
import { PackageHeader } from './package-header'; import { PackageHeader } from './package-header';
import { pathsConfig } from '../config';
export type AnalysisPackageWithVariant = Pick<StoreProduct, 'title' | 'description' | 'subtitle' | 'metadata'> & { export type AnalysisPackageWithVariant = Pick<StoreProduct, 'title' | 'description' | 'subtitle' | 'metadata'> & {
variantId: string; variantId: string;
@@ -57,7 +58,7 @@ export default function SelectAnalysisPackage({
}); });
setIsAddingToCart(false); setIsAddingToCart(false);
toast.success(<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />); toast.success(<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />);
router.push('/home/cart'); router.push(pathsConfig.app.cart);
} catch (e) { } catch (e) {
toast.error(<Trans i18nKey={'order-analysis-package:analysisPackageAddToCartError'} />); toast.error(<Trans i18nKey={'order-analysis-package:analysisPackageAddToCartError'} />);
setIsAddingToCart(false); setIsAddingToCart(false);

View File

@@ -0,0 +1,24 @@
'use client'
import { DropdownMenuItem } from "@kit/ui/dropdown-menu";
import { Trans } from "@kit/ui/trans";
import { LogOut } from "lucide-react";
export default function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

@@ -0,0 +1,33 @@
'use client'
import { DropdownMenuItem } from "@kit/ui/dropdown-menu";
import { Trans } from "@kit/ui/trans";
import Link from "next/link";
export default function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
labelOptions?: Record<string, any>;
Icon?: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem asChild key={props.path}>
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans
i18nKey={props.label}
defaults={props.label}
values={props.labelOptions}
/>
</span>
</Link>
</DropdownMenuItem>
);
}

View File

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

View File

@@ -6,32 +6,30 @@ const AppConfigSchema = z
.object({ .object({
name: z name: z
.string({ .string({
description: `This is the name of your SaaS. Ex. "Makerkit"`, error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`,
required_error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`,
}) })
.describe(`This is the name of your SaaS. Ex. "Makerkit"`)
.min(1), .min(1),
title: z title: z
.string({ .string({
description: `This is the default title tag of your SaaS.`, error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`,
required_error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`,
}) })
.describe(`This is the default title tag of your SaaS.`)
.min(1), .min(1),
description: z.string({ description: z.string({
description: `This is the default description of your SaaS.`, error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`,
required_error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`, })
.describe(`This is the default description of your SaaS.`),
url: z.url({
error: (issue) => issue.input === undefined
? "Please provide the variable NEXT_PUBLIC_SITE_URL"
: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`
}), }),
url: z
.string({
required_error: `Please provide the variable NEXT_PUBLIC_SITE_URL`,
})
.url({
message: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`,
}),
locale: z locale: z
.string({ .string({
description: `This is the default locale of your SaaS.`, error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`,
required_error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`,
}) })
.describe(`This is the default locale of your SaaS.`)
.default('en'), .default('en'),
theme: z.enum(['light', 'dark', 'system']), theme: z.enum(['light', 'dark', 'system']),
production: z.boolean(), production: z.boolean(),

View File

@@ -6,22 +6,14 @@ const providers: z.ZodType<Provider> = getProviders();
const AuthConfigSchema = z.object({ const AuthConfigSchema = z.object({
captchaTokenSiteKey: z captchaTokenSiteKey: z
.string({ .string().describe('The reCAPTCHA site key.')
description: 'The reCAPTCHA site key.',
})
.optional(), .optional(),
displayTermsCheckbox: z displayTermsCheckbox: z
.boolean({ .boolean().describe('Whether to display the terms checkbox during sign-up.')
description: 'Whether to display the terms checkbox during sign-up.',
})
.optional(), .optional(),
providers: z.object({ providers: z.object({
password: z.boolean({ password: z.boolean().describe('Enable password authentication.'),
description: 'Enable password authentication.', magicLink: z.boolean().describe('Enable magic link authentication.'),
}),
magicLink: z.boolean({
description: 'Enable magic link authentication.',
}),
oAuth: providers.array(), oAuth: providers.array(),
}), }),
}); });
@@ -40,7 +32,7 @@ const authConfig = AuthConfigSchema.parse({
providers: { providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true', password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true', magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
oAuth: ['google'], oAuth: ['keycloak'],
}, },
} satisfies z.infer<typeof AuthConfigSchema>); } satisfies z.infer<typeof AuthConfigSchema>);

View File

@@ -4,56 +4,56 @@ type LanguagePriority = 'user' | 'application';
const FeatureFlagsSchema = z.object({ const FeatureFlagsSchema = z.object({
enableThemeToggle: z.boolean({ enableThemeToggle: z.boolean({
description: 'Enable theme toggle in the user interface.', error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE', })
}), .describe( 'Enable theme toggle in the user interface.'),
enableAccountDeletion: z.boolean({ enableAccountDeletion: z.boolean({
description: 'Enable personal account deletion.', error:
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION',
}), })
.describe('Enable personal account deletion.'),
enableTeamDeletion: z.boolean({ enableTeamDeletion: z.boolean({
description: 'Enable team deletion.', error:
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION', 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION',
}), })
.describe('Enable team deletion.'),
enableTeamAccounts: z.boolean({ enableTeamAccounts: z.boolean({
description: 'Enable team accounts.', error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', })
}), .describe('Enable team accounts.'),
enableTeamCreation: z.boolean({ enableTeamCreation: z.boolean({
description: 'Enable team creation.', error:
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION',
}), })
.describe('Enable team creation.'),
enablePersonalAccountBilling: z.boolean({ enablePersonalAccountBilling: z.boolean({
description: 'Enable personal account billing.', error:
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING',
}), })
.describe('Enable personal account billing.'),
enableTeamAccountBilling: z.boolean({ enableTeamAccountBilling: z.boolean({
description: 'Enable team account billing.', error:
required_error:
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING',
}), })
.describe('Enable team account billing.'),
languagePriority: z languagePriority: z
.enum(['user', 'application'], { .enum(['user', 'application'], {
required_error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY', error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY',
description: `If set to user, use the user's preferred language. If set to application, use the application's default language.`,
}) })
.describe(`If set to user, use the user's preferred language. If set to application, use the application's default language.`)
.default('application'), .default('application'),
enableNotifications: z.boolean({ enableNotifications: z.boolean({
description: 'Enable notifications functionality', error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS', })
}), .describe('Enable notifications functionality'),
realtimeNotifications: z.boolean({ realtimeNotifications: z.boolean({
description: 'Enable realtime for the notifications functionality', error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS', })
}), .describe('Enable realtime for the notifications functionality'),
enableVersionUpdater: z.boolean({ enableVersionUpdater: z.boolean({
description: 'Enable version updater', error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER',
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER', })
}), .describe('Enable version updater'),
}); });
const featureFlagsConfig = FeatureFlagsSchema.parse({ const featureFlagsConfig = FeatureFlagsSchema.parse({

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