Compare commits
4 Commits
keycloak-u
...
keycloak
| Author | SHA1 | Date | |
|---|---|---|---|
| bc61db07b2 | |||
| 3d58e5aa84 | |||
| 8ecca096f2 | |||
| 84c8dcc792 |
3
.env
3
.env
@@ -65,6 +65,3 @@ 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==
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
@@ -37,3 +36,57 @@ MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
|||||||
|
|
||||||
# JOBS
|
# JOBS
|
||||||
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
|
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
|
||||||
|
|
||||||
|
# MEDIPOST
|
||||||
|
|
||||||
|
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
|
||||||
|
MEDIPOST_USER=trvurgtst
|
||||||
|
MEDIPOST_PASSWORD=SRB48HZMV
|
||||||
|
MEDIPOST_RECIPIENT=trvurgtst
|
||||||
|
MEDIPOST_MESSAGE_SENDER=trvurgtst
|
||||||
|
|
||||||
|
MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
|
||||||
|
MEDIPOST_USER=medreport
|
||||||
|
MEDIPOST_PASSWORD=85MXFFDB7
|
||||||
|
MEDIPOST_RECIPIENT=HTI
|
||||||
|
MEDIPOST_MESSAGE_SENDER=medreport
|
||||||
|
|
||||||
|
### TEST.MEDREPORT.ee ###
|
||||||
|
|
||||||
|
DB_PASSWORD=T#u-$M7%RjbA@L@
|
||||||
|
|
||||||
|
#### MEDUSA
|
||||||
|
MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
|
||||||
|
MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
|
||||||
|
MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
|
||||||
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_827a2ab863021cb67993f1d81078f81bfce4b4e0da642d8c0f5398ded9d8fd32
|
||||||
|
|
||||||
|
#### SUPABASE
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
|
||||||
|
|
||||||
|
#######
|
||||||
|
|
||||||
|
### LOCAL ###
|
||||||
|
|
||||||
|
#### MEDUSA
|
||||||
|
MEDUSA_BACKEND_URL=http://localhost:9000
|
||||||
|
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
|
||||||
|
#MEDUSA_BACKEND_URL=http://5.181.51.38:9000
|
||||||
|
#MEDUSA_BACKEND_PUBLIC_URL=http://5.181.51.38:9000
|
||||||
|
MEDUSA_SECRET_API_KEY=sk_b332d525212ab4078ef73fb2b8232c3beebccc4a460e2c7abf6e187a458d60cf
|
||||||
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb0768ff4520516285bdef84
|
||||||
|
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
|
||||||
|
|
||||||
|
#### SUPABASE
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||||
|
|
||||||
|
#######
|
||||||
|
|
||||||
|
SUPABASE_AUTH_CLIENT_ID=supabase
|
||||||
|
SUPABASE_AUTH_KEYCLOAK_SECRET=Gl394GjizClhQl06KFeoFyZ7ZbPamG5I
|
||||||
|
SUPABASE_AUTH_KEYCLOAK_URL=http://localhost:8585/realms/medreport-sandbox
|
||||||
|
SUPABASE_AUTH_KEYCLOAK_CALLBACK_URL=http://localhost:3000/auth/callback
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
.env.staging
10
.env.staging
@@ -6,10 +6,10 @@
|
|||||||
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
||||||
|
|
||||||
# SUPABASE
|
# SUPABASE
|
||||||
# NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
|
NEXT_PUBLIC_SUPABASE_URL=https://kaldvociniytdbbcxvqk.supabase.co
|
||||||
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImthbGR2b2Npbml5dGRiYmN4dnFrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYzNjQ5OTYsImV4cCI6MjA3MTk0MDk5Nn0.eixihH2KGkJZolY9FiQDicJOo2kxvXrSe6gGUCrkLo0
|
||||||
|
|
||||||
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
|
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
|
||||||
|
|
||||||
# # MONTONIO
|
# MONTONIO
|
||||||
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ EMAIL_PASSWORD=password
|
|||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
||||||
|
|
||||||
CONTACT_EMAIL=test@makerkit.dev
|
CONTACT_EMAIL=test@makerkit.dev
|
||||||
|
|
||||||
|
SUPABASE_AUTH_KEYCLOAK_URL=https://keycloak.medreport.ee/realms/medreport-prod
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -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
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import { Button } from '@kit/ui/button';
|
|||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
import { featureFlagsConfig } 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) => ({
|
||||||
@@ -72,13 +75,11 @@ function AuthButtons() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{authConfig.providers.password && (
|
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
<Link href={pathsConfig.auth.signUp}>
|
<Trans i18nKey={'auth:signUp'} />
|
||||||
<Trans i18nKey={'auth:signUp'} />
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import { ContactEmailSchema } from '../contact-email.schema';
|
|||||||
|
|
||||||
const contactEmail = z
|
const contactEmail = z
|
||||||
.string({
|
.string({
|
||||||
error:
|
description: `The email where you want to receive the contact form submissions.`,
|
||||||
|
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({
|
||||||
error:
|
description: `The email sending address.`,
|
||||||
|
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(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
@@ -33,7 +32,7 @@ function MainCallToActionButton() {
|
|||||||
return (
|
return (
|
||||||
<div className={'flex space-x-4'}>
|
<div className={'flex space-x-4'}>
|
||||||
<CtaButton>
|
<CtaButton>
|
||||||
<Link href={pathsConfig.auth.signUp}>
|
<Link href={'/auth/sign-up'}>
|
||||||
<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'} />
|
||||||
|
|||||||
@@ -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 { getOrderedAnalysisIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
|
import { getOrderedAnalysisElementsIds, 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 getOrderedAnalysisIds({ medusaOrder });
|
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ 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({
|
||||||
|
|||||||
@@ -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 { getOrderedAnalysisIds } from "~/lib/services/medipost.service";
|
import { getOrderedAnalysisElementsIds } 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 getOrderedAnalysisIds({ medusaOrder });
|
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ 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).filter(Boolean) as number[],
|
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
|
||||||
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
orderedAnalysesIds: [],
|
||||||
orderId: medusaOrderId,
|
orderId: medusaOrderId,
|
||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getOrder } from "~/lib/services/order.service";
|
|||||||
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
import { 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, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
|
import { createMedipostActionLog, getOrderedAnalysisElementsIds } 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,15 +11,16 @@ 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 } = await request.json();
|
const { medusaOrderId, maxItems = null } = 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 getOrderedAnalysisIds({ medusaOrder });
|
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ 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} (${maxItems ?? 'all'}) 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!,
|
||||||
@@ -27,8 +28,8 @@ export async function POST(request: Request) {
|
|||||||
lastName: account.last_name ?? '',
|
lastName: account.last_name ?? '',
|
||||||
phone: account.phone ?? '',
|
phone: account.phone ?? '',
|
||||||
},
|
},
|
||||||
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
|
||||||
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
orderedAnalysesIds: [],
|
||||||
orderId: medusaOrderId,
|
orderId: medusaOrderId,
|
||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,62 +1,19 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
|
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
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 api = createAccountsApi(getSupabaseServerClient());
|
const { nextPath } = await service.exchangeCodeForSession(request, {
|
||||||
|
joinTeamPath: pathsConfig.app.joinTeam,
|
||||||
|
redirectPath: pathsConfig.app.home,
|
||||||
|
});
|
||||||
|
|
||||||
const account = await api.getPersonalAccountByUserId(
|
return redirect(nextPath);
|
||||||
oauthResult.user.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!account.email || !account.name || !account.last_name) {
|
|
||||||
return redirect(pathsConfig.auth.updateAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect(redirectPath);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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());
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Loading from '@/app/home/loading';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
export function SignInPageClientRedirect() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function signIn() {
|
|
||||||
const { data, error } = await getSupabaseBrowserClient()
|
|
||||||
.auth
|
|
||||||
.signInWithOAuth({
|
|
||||||
provider: 'keycloak',
|
|
||||||
options: {
|
|
||||||
redirectTo: `${window.location.origin}/auth/callback`,
|
|
||||||
queryParams: {
|
|
||||||
prompt: 'login',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('OAuth error', error);
|
|
||||||
router.push('/');
|
|
||||||
} else if (data.url) {
|
|
||||||
router.push(data.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
signIn();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { pathsConfig, authConfig } from '@kit/shared/config';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
|
||||||
|
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||||
|
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { 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<{
|
||||||
@@ -21,14 +26,47 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
|
const { invite_token: inviteToken, next = pathsConfig.app.home } =
|
||||||
await searchParams;
|
await searchParams;
|
||||||
|
|
||||||
if (authConfig.providers.password) {
|
const signUpPath =
|
||||||
return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
|
pathsConfig.auth.signUp +
|
||||||
}
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
return <SignInPageClientRedirect />;
|
const paths = {
|
||||||
|
callback: pathsConfig.auth.callback,
|
||||||
|
returnPath: next ?? pathsConfig.app.home,
|
||||||
|
joinTeam: pathsConfig.app.joinTeam,
|
||||||
|
updateAccount: pathsConfig.auth.updateAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
<Heading level={4} className={'tracking-tight'}>
|
||||||
|
<Trans i18nKey={'auth:signInHeading'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-sm'}>
|
||||||
|
<Trans i18nKey={'auth:signInSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignInMethodsContainer
|
||||||
|
inviteToken={inviteToken}
|
||||||
|
paths={paths}
|
||||||
|
providers={authConfig.providers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'flex justify-center'}>
|
||||||
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
|
<Link href={signUpPath} prefetch={true}>
|
||||||
|
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(SignInPage);
|
export default withI18n(SignInPage);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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';
|
||||||
@@ -38,10 +37,6 @@ 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'}>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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';
|
||||||
@@ -21,52 +23,31 @@ 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';
|
|
||||||
|
|
||||||
type UpdateAccountFormValues = z.infer<typeof UpdateAccountSchema>;
|
export function UpdateAccountForm({ user }: { user: User }) {
|
||||||
|
|
||||||
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(onUpdateAccountOptions)}
|
onSubmit={form.handleSubmit(onUpdateAccount)}
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
name="firstName"
|
name="firstName"
|
||||||
disabled={hasFirstName}
|
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -82,7 +63,6 @@ export function UpdateAccountForm({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="lastName"
|
name="lastName"
|
||||||
disabled={hasLastName}
|
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -98,7 +78,6 @@ export function UpdateAccountForm({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="personalCode"
|
name="personalCode"
|
||||||
disabled={hasPersonalCode}
|
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -114,14 +93,13 @@ export function UpdateAccountForm({
|
|||||||
|
|
||||||
<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} />
|
<Input {...field} disabled />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
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({
|
||||||
error: 'First name is required',
|
required_error: 'First name is required',
|
||||||
})
|
})
|
||||||
.nonempty(),
|
.nonempty(),
|
||||||
lastName: z
|
lastName: z
|
||||||
.string({
|
.string({
|
||||||
error: 'Last name is required',
|
required_error: 'Last name is required',
|
||||||
})
|
})
|
||||||
.nonempty(),
|
.nonempty(),
|
||||||
personalCode: z
|
personalCode: z
|
||||||
.string({
|
.string({
|
||||||
error: 'Personal code is required',
|
required_error: 'Personal code is required',
|
||||||
})
|
})
|
||||||
.nonempty(),
|
.nonempty(),
|
||||||
email: z.string().email({
|
email: z.string().email({
|
||||||
@@ -22,38 +21,21 @@ export const UpdateAccountSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
phone: z
|
phone: z
|
||||||
.string({
|
.string({
|
||||||
error: 'Phone number is required',
|
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({
|
||||||
error: (issue) =>
|
required_error: 'Weight is required',
|
||||||
issue.input === undefined
|
invalid_type_error: 'Weight must be a number',
|
||||||
? '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({
|
||||||
error: (issue) =>
|
required_error: 'Height is required',
|
||||||
issue.input === undefined
|
invalid_type_error: 'Height must be a number',
|
||||||
? '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, {
|
||||||
|
|||||||
@@ -28,15 +28,11 @@ export const onUpdateAccount = enhanceAction(
|
|||||||
console.warn('On update account error: ', err);
|
console.warn('On update account error: ', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await updateCustomer({
|
||||||
await updateCustomer({
|
first_name: params.firstName,
|
||||||
first_name: params.firstName,
|
last_name: params.lastName,
|
||||||
last_name: params.lastName,
|
phone: params.phone,
|
||||||
phone: params.phone,
|
});
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to update Medusa customer", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUnseenMembershipConfirmation =
|
const hasUnseenMembershipConfirmation =
|
||||||
await api.hasUnseenMembershipConfirmation();
|
await api.hasUnseenMembershipConfirmation();
|
||||||
|
|||||||
@@ -11,39 +11,18 @@ 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">
|
||||||
@@ -55,7 +34,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 defaultValues={defaultValues} />
|
<UpdateAccountForm user={user} />
|
||||||
</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>
|
||||||
|
|||||||
@@ -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 { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
|
import { getOrderedAnalysisElementsIds, 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({
|
||||||
error: 'EMAIL_SENDER is required',
|
required_error: 'EMAIL_SENDER is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
siteUrl: z
|
siteUrl: z
|
||||||
.string({
|
.string({
|
||||||
error: 'NEXT_PUBLIC_SITE_URL is required',
|
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 getOrderedAnalysisIds({ medusaOrder });
|
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
|
||||||
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
|
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
|
||||||
|
|
||||||
const { productTypes } = await listProductTypes();
|
const { productTypes } = await listProductTypes();
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
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 { pathsConfig } from '@kit/shared/config';
|
import {
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -18,11 +24,40 @@ 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());
|
||||||
@@ -66,3 +101,27 @@ 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async function UserHomePage() {
|
|||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const account = await loadCurrentUserAccount();
|
const account = await loadCurrentUserAccount();
|
||||||
const api = createAccountsApi(client);
|
const api = await createAccountsApi(client);
|
||||||
const bmiThresholds = await api.fetchBmiThresholds();
|
const bmiThresholds = await api.fetchBmiThresholds();
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Badge, Text } from "@medusajs/ui"
|
import { Badge, Heading, 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"
|
||||||
@@ -32,19 +31,11 @@ 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'));
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,14 +45,7 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -80,7 +64,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 sm:flex-row flex-col gap-y-2"
|
className="w-full mb-2 flex gap-x-2"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
name={'code'}
|
name={'code'}
|
||||||
@@ -103,12 +87,16 @@ export default function DiscountCode({ cart }: {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{promotions.length > 0 ? (
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="w-full flex items-center mt-4">
|
<Trans i18nKey={'cart:discountCode.subtitle'} />
|
||||||
<div className="flex flex-col w-full gap-y-2">
|
</p>
|
||||||
<p>
|
|
||||||
<Trans i18nKey={'cart:discountCode.appliedCodes'} />
|
{promotions.length > 0 && (
|
||||||
</p>
|
<div className="w-full flex items-center">
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<Heading className="txt-medium mb-2">
|
||||||
|
Promotion(s) applied:
|
||||||
|
</Heading>
|
||||||
|
|
||||||
{promotions.map((promotion) => {
|
{promotions.map((promotion) => {
|
||||||
return (
|
return (
|
||||||
@@ -122,7 +110,6 @@ 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>{" "}
|
||||||
@@ -164,7 +151,7 @@ export default function DiscountCode({ cart }: {
|
|||||||
>
|
>
|
||||||
<Trash size={14} />
|
<Trash size={14} />
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
<Trans i18nKey={'cart:discountCode.remove'} />
|
Remove discount code from order
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -173,10 +160,6 @@ export default function DiscountCode({ cart }: {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<Trans i18nKey={'cart:discountCode.subtitle'} />
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = true as boolean;
|
const IS_DISCOUNT_SHOWN = false as boolean;
|
||||||
|
|
||||||
export default function Cart({
|
export default function Cart({
|
||||||
cart,
|
cart,
|
||||||
@@ -77,62 +77,28 @@ 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="flex justify-end gap-x-4 px-6 pt-4">
|
<div className="mr-[36px]">
|
||||||
<div className="mr-[36px]">
|
<p className="ml-0 font-bold text-sm">
|
||||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
<Trans i18nKey="cart:total" />
|
||||||
<Trans i18nKey="cart:subtotal" />
|
</p>
|
||||||
</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="flex justify-end gap-x-4 px-6 py-2">
|
<div className="mr-[116px]">
|
||||||
<div className="mr-[36px]">
|
<p className="text-sm">
|
||||||
<p className="ml-0 font-bold text-sm text-muted-foreground">
|
{formatCurrency({
|
||||||
<Trans i18nKey="cart:promotionsTotal" />
|
value: cart.total,
|
||||||
</p>
|
currencyCode: cart.currency_code,
|
||||||
</div>
|
locale: language,
|
||||||
<div className="mr-[116px]">
|
})}
|
||||||
<p className="text-sm">
|
</p>
|
||||||
{formatCurrency({
|
|
||||||
value: cart.discount_total,
|
|
||||||
currencyCode: cart.currency_code,
|
|
||||||
locale: language,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-x-4 px-6">
|
</div>
|
||||||
<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 sm:flex-row flex-col gap-y-6 py-8 gap-x-4">
|
<div className="flex gap-y-6 py-8">
|
||||||
{IS_DISCOUNT_SHOWN && (
|
{IS_DISCOUNT_SHOWN && (
|
||||||
<Card
|
<Card
|
||||||
className="flex flex-col justify-between w-full sm:w-1/2"
|
className="flex flex-col justify-between w-1/2"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<h5>
|
<h5>
|
||||||
@@ -147,7 +113,7 @@ export default function Cart({
|
|||||||
|
|
||||||
{isLocationsShown && (
|
{isLocationsShown && (
|
||||||
<Card
|
<Card
|
||||||
className="flex flex-col justify-between w-full sm:w-1/2"
|
className="flex flex-col justify-between w-1/2"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<h5>
|
<h5>
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,41 +7,33 @@ 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 { pathsConfig } from '@kit/shared/config';
|
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
|
||||||
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,
|
||||||
@@ -49,91 +41,111 @@ 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:
|
// description: 'dashboard:respondToQuestion',
|
||||||
isNil(smoking)
|
// descriptionColor: 'text-primary',
|
||||||
? 'dashboard:respondToQuestion'
|
// icon: (
|
||||||
: !!smoking
|
// <Button size="icon" variant="outline" className="px-2 text-black">
|
||||||
? 'common:yes'
|
// <ChevronRight className="size-4 stroke-2" />
|
||||||
: 'common:no',
|
// </Button>
|
||||||
descriptionColor: 'text-primary',
|
// ),
|
||||||
icon:
|
// cardVariant: 'gradient-success' as CardProps['variant'],
|
||||||
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 IS_SHOWN_RECOMMENDATIONS = false as boolean;
|
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 Dashboard({
|
export default function Dashboard({
|
||||||
account,
|
account,
|
||||||
@@ -148,8 +160,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.accountParams?.height || 0,
|
height: account.account_params?.[0]?.height || 0,
|
||||||
weight: account.accountParams?.weight || 0,
|
weight: account.account_params?.[0]?.weight || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,22 +170,21 @@ export default function Dashboard({
|
|||||||
{cards({
|
{cards({
|
||||||
gender: params?.gender,
|
gender: params?.gender,
|
||||||
age: params?.age,
|
age: params?.age,
|
||||||
height: account.accountParams?.height,
|
height: account.account_params?.[0]?.height,
|
||||||
weight: account.accountParams?.weight,
|
weight: account.account_params?.[0]?.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">
|
||||||
@@ -200,7 +211,79 @@ export default function Dashboard({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{IS_SHOWN_RECOMMENDATIONS && <DashboardRecommendations />}
|
<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 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ export async function HomeMenuNavigation(props: {
|
|||||||
})
|
})
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const cartQuantityTotal =
|
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
|
||||||
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
|
|
||||||
const hasCartItems = cartQuantityTotal > 0;
|
const hasCartItems = cartQuantityTotal > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,18 +39,15 @@ 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>
|
||||||
{/* TODO: add search functionality */}
|
<Search
|
||||||
{/* <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"
|
||||||
@@ -60,7 +56,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={pathsConfig.app.cart}>
|
<Link href="/home/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"
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ 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, Menu, Shield, ShoppingCart } from 'lucide-react';
|
import { Cross, LogOut, 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,
|
||||||
@@ -93,7 +91,7 @@ export function HomeMobileNavigation(props: {
|
|||||||
<If condition={props.cart && hasCartItems}>
|
<If condition={props.cart && hasCartItems}>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownLink
|
<DropdownLink
|
||||||
path={pathsConfig.app.cart}
|
path="/home/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 }}
|
||||||
@@ -147,4 +145,49 @@ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function OrderAnalysesCards({
|
|||||||
className="px-2 text-black"
|
className="px-2 text-black"
|
||||||
onClick={() => handleSelect(variant.id)}
|
onClick={() => handleSelect(variant.id)}
|
||||||
>
|
>
|
||||||
{variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
{variantAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -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?.id
|
return user?.identities?.[0]?.id
|
||||||
? await loadUserAccount(user.id)
|
? await loadUserAccount(user?.identities?.[0]?.id)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accountLoader(userId: string) {
|
async function accountLoader(accountId: string) {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const api = createAccountsApi(client);
|
const api = createAccountsApi(client);
|
||||||
|
|
||||||
return api.getPersonalAccountByUserId(userId);
|
return api.getAccount(accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
'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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
'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 }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,69 +1,23 @@
|
|||||||
import { use } from 'react';
|
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||||
|
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';
|
|
||||||
|
|
||||||
function UserSettingsLayout({ children }: React.PropsWithChildren) {
|
// local imports
|
||||||
return <HeaderLayout>{children}</HeaderLayout>;
|
import { HomeLayoutPageHeader } from '../_components/home-page-header';
|
||||||
}
|
|
||||||
|
|
||||||
export default withI18n(UserSettingsLayout);
|
function UserSettingsLayout(props: React.PropsWithChildren) {
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppLogo href={pathsConfig.app.home} />
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'account:routes.settings'} />}
|
||||||
|
description={<AppBreadcrumbs />}
|
||||||
|
/>
|
||||||
|
|
||||||
<SettingsMobileNavigation workspace={workspace} cart={cart} />
|
{props.children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withI18n(UserSettingsLayout);
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
|
const features = {
|
||||||
import AccountSettingsForm from './_components/account-settings-form';
|
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
|
||||||
import SettingsSectionHeader from './_components/settings-section-header';
|
enablePasswordUpdate: authConfig.providers.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -16,18 +33,17 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
async function PersonalAccountSettingsPage() {
|
function PersonalAccountSettingsPage() {
|
||||||
const account = await loadCurrentUserAccount();
|
const user = use(requireUserInServerComponent());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<div className="mx-auto w-full bg-white p-6">
|
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
|
||||||
<div className="space-y-6">
|
<PersonalAccountSettingsContainer
|
||||||
<SettingsSectionHeader
|
userId={user.id}
|
||||||
titleKey="account:accountTabLabel"
|
features={features}
|
||||||
descriptionKey="account:accountTabDescription"
|
paths={paths}
|
||||||
/>
|
/>
|
||||||
<AccountSettingsForm account={account} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
|
import { Home, LogOut, Menu } from 'lucide-react';
|
||||||
import { Home, Menu } from 'lucide-react';
|
|
||||||
|
|
||||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||||
import {
|
import {
|
||||||
@@ -93,7 +92,47 @@ 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;
|
||||||
|
|||||||
@@ -3,26 +3,9 @@
|
|||||||
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 client = await createClient();
|
const supabase = 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('/');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export const defaultI18nNamespaces = [
|
|||||||
'orders',
|
'orders',
|
||||||
'analysis-results',
|
'analysis-results',
|
||||||
'doctor',
|
'doctor',
|
||||||
'error',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
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'>;
|
||||||
|
|
||||||
@@ -67,9 +61,7 @@ export async function getDoctorAccounts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAssignedDoctorAccount(analysisOrderId: number) {
|
export async function getAssignedDoctorAccount(analysisOrderId: number) {
|
||||||
const supabase = getSupabaseServerAdminClient();
|
const { data: doctorUser } = await 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')
|
||||||
@@ -81,7 +73,7 @@ export async function getAssignedDoctorAccount(analysisOrderId: number) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await getSupabaseServerAdminClient()
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select('email')
|
.select('email')
|
||||||
@@ -89,58 +81,3 @@ 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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,18 +105,12 @@ export const createMedusaSyncSuccessEntry = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnalyses({ ids, originalIds }: { ids?: number[], originalIds?: string[] }): Promise<AnalysesWithGroupsAndElements> {
|
export async function getAnalyses({ ids }: { ids: number[] }): Promise<AnalysesWithGroupsAndElements> {
|
||||||
const query = getSupabaseServerAdminClient()
|
const { data } = await getSupabaseServerAdminClient()
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('analyses')
|
.from('analyses')
|
||||||
.select(`*, analysis_elements(*, analysis_groups(*))`);
|
.select(`*, analysis_elements(*, analysis_groups(*))`)
|
||||||
if (Array.isArray(ids)) {
|
.in('id', 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
getClientInstitution,
|
getClientInstitution,
|
||||||
getClientPerson,
|
getClientPerson,
|
||||||
getConfidentiality,
|
getConfidentiality,
|
||||||
getOrderEnteredPerson,
|
|
||||||
getPais,
|
getPais,
|
||||||
getPatient,
|
getPatient,
|
||||||
getProviderInstitution,
|
getProviderInstitution,
|
||||||
@@ -554,12 +553,14 @@ 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()}
|
||||||
${getClientPerson()}
|
<!--<TellijaIsik>-->
|
||||||
${getOrderEnteredPerson()}
|
${getClientPerson(person)}
|
||||||
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
|
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
|
||||||
${getPatient(person)}
|
${getPatient(person)}
|
||||||
${getConfidentiality()}
|
${getConfidentiality()}
|
||||||
${specimenSection.join('')}
|
${specimenSection.join('')}
|
||||||
${analysisSection?.join('')}
|
${analysisSection?.join('')}
|
||||||
@@ -665,7 +666,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
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -714,7 +715,7 @@ export async function sendOrderToMedipost({
|
|||||||
orderedAnalysisElements,
|
orderedAnalysisElements,
|
||||||
}: {
|
}: {
|
||||||
medusaOrderId: string;
|
medusaOrderId: string;
|
||||||
orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
|
orderedAnalysisElements: { analysisElementId: 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 });
|
||||||
@@ -726,8 +727,8 @@ export async function sendOrderToMedipost({
|
|||||||
lastName: account.last_name ?? '',
|
lastName: account.last_name ?? '',
|
||||||
phone: account.phone ?? '',
|
phone: account.phone ?? '',
|
||||||
},
|
},
|
||||||
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId),
|
||||||
orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
orderedAnalysesIds: [],
|
||||||
orderId: medusaOrderId,
|
orderId: medusaOrderId,
|
||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
comment: '',
|
comment: '',
|
||||||
@@ -783,13 +784,12 @@ export async function sendOrderToMedipost({
|
|||||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrderedAnalysisIds({
|
export async function getOrderedAnalysisElementsIds({
|
||||||
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,14 +802,6 @@ export async function getOrderedAnalysisIds({
|
|||||||
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[];
|
||||||
@@ -849,13 +841,12 @@ export async function getOrderedAnalysisIds({
|
|||||||
return analysisElements.map(({ id }) => ({ analysisElementId: id }));
|
return analysisElements.map(({ id }) => ({ analysisElementId: id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [analysisPackageElements, orderedAnalysisElements, orderedAnalyses] = await Promise.all([
|
const [analysisPackageElements, orderedAnalysisElements] = await Promise.all([
|
||||||
getOrderedAnalysisPackages(medusaOrder),
|
getOrderedAnalysisPackages(medusaOrder),
|
||||||
getOrderedAnalysisElements(medusaOrder),
|
getOrderedAnalysisElements(medusaOrder),
|
||||||
getOrderedAnalyses(medusaOrder),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [...analysisPackageElements, ...orderedAnalysisElements, ...orderedAnalyses];
|
return [...analysisPackageElements, ...orderedAnalysisElements];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMedipostActionLog({
|
export async function createMedipostActionLog({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import {
|
import {
|
||||||
getClientInstitution,
|
getClientInstitution,
|
||||||
getClientPerson,
|
getClientPerson,
|
||||||
getOrderEnteredPerson,
|
|
||||||
getPais,
|
getPais,
|
||||||
getPatient,
|
getPatient,
|
||||||
getProviderInstitution,
|
getProviderInstitution,
|
||||||
@@ -105,8 +104,7 @@ export async function composeOrderTestResponseXML({
|
|||||||
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
||||||
${getClientInstitution({ index: 1 })}
|
${getClientInstitution({ index: 1 })}
|
||||||
${getProviderInstitution({ index: 1 })}
|
${getProviderInstitution({ index: 1 })}
|
||||||
${getClientPerson()}
|
${getClientPerson(person)}
|
||||||
${getOrderEnteredPerson()}
|
|
||||||
<TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused>
|
<TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused>
|
||||||
|
|
||||||
${getPatient(person)}
|
${getPatient(person)}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ const env = () =>
|
|||||||
.object({
|
.object({
|
||||||
medusaBackendPublicUrl: z
|
medusaBackendPublicUrl: z
|
||||||
.string({
|
.string({
|
||||||
error: 'MEDUSA_BACKEND_PUBLIC_URL is required',
|
required_error: 'MEDUSA_BACKEND_PUBLIC_URL is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
siteUrl: z
|
siteUrl: z
|
||||||
.string({
|
.string({
|
||||||
error: 'NEXT_PUBLIC_SITE_URL is required',
|
required_error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function createOrder({
|
|||||||
orderedAnalysisElements,
|
orderedAnalysisElements,
|
||||||
}: {
|
}: {
|
||||||
medusaOrder: StoreOrder;
|
medusaOrder: StoreOrder;
|
||||||
orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
|
orderedAnalysisElements: { analysisElementId: 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).filter(Boolean) as number[],
|
analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId),
|
||||||
analysis_ids: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
analysis_ids: [],
|
||||||
status: 'QUEUED',
|
status: 'QUEUED',
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
medusa_order_id: medusaOrder.id,
|
medusa_order_id: medusaOrder.id,
|
||||||
|
|||||||
@@ -21,48 +21,70 @@ 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>info@medreport.ee</Email>
|
<Email>argo@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>MRP</AsutuseKood>
|
<AsutuseKood>TSU</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 Eesti OÜ</AsutuseNimi>
|
<AsutuseNimi>Synlab HTI Tallinn</AsutuseNimi>
|
||||||
<AsutuseKood>HTI</AsutuseKood>
|
<AsutuseKood>SLA</AsutuseKood>
|
||||||
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
|
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
|
||||||
<Telefon>+37217123</Telefon>
|
<Telefon>+3723417123</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>D07907</PersonalKood>
|
<PersonalKood>${idCode}</PersonalKood>
|
||||||
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
|
<PersonalPerekonnaNimi>${lastName}</PersonalPerekonnaNimi>
|
||||||
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
|
<PersonalEesNimi>${firstName}</PersonalEesNimi>
|
||||||
<Telefon>+37258131202</Telefon>
|
${phone ? `<Telefon>${phone.startsWith('+372') ? phone : `+372${phone}`}</Telefon>` : ''}
|
||||||
</Personal>`;
|
</Personal>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrderEnteredPerson = () => {
|
// export const getOrderEnteredPerson = () => {
|
||||||
return `<Personal tyyp="SISESTAJA" jarjenumber="2">
|
// if (isProd) {
|
||||||
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
|
// // return correct data
|
||||||
<PersonalKood>D07907</PersonalKood>
|
// }
|
||||||
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
|
// return `<Personal tyyp="SISESTAJA" jarjenumber="1">
|
||||||
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
|
// <PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
|
||||||
<Telefon>+37258131202</Telefon>
|
// <PersonalKood>D07907</PersonalKood>
|
||||||
</Personal>`;
|
// <PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
|
||||||
};
|
// <PersonalEesNimi>Tsvetkov</PersonalEesNimi>
|
||||||
|
// <Telefon>+37258131202</Telefon>
|
||||||
|
// </Personal>`;
|
||||||
|
// };
|
||||||
|
|
||||||
export const getPatient = ({
|
export const getPatient = ({
|
||||||
idCode,
|
idCode,
|
||||||
|
|||||||
23
lib/utils.ts
23
lib/utils.ts
@@ -15,12 +15,11 @@ export function toArray<T>(input?: T | T[] | null): T[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toTitleCase(str?: string) {
|
export function toTitleCase(str?: string) {
|
||||||
return (
|
if (!str) return '';
|
||||||
str
|
return str.replace(
|
||||||
?.toLowerCase()
|
/\w\S*/g,
|
||||||
.replace(/[^-'’\s]+/g, (match) =>
|
(text: string) =>
|
||||||
match.replace(/^./, (first) => first.toUpperCase()),
|
text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||||
) ?? ""
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,12 +40,8 @@ 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 m2 = m * m;
|
const bmi = kg / (m * m);
|
||||||
if (m2 === 0) {
|
return bmi ? Math.round(bmi) : NaN;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const bmi = kg / m2;
|
|
||||||
return !Number.isNaN(bmi) ? Math.round(bmi) : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getBmiStatus(
|
export function getBmiStatus(
|
||||||
@@ -63,9 +58,7 @@ export function getBmiStatus(
|
|||||||
) || null;
|
) || null;
|
||||||
const bmi = bmiFromMetric(params.weight, params.height);
|
const bmi = bmiFromMetric(params.weight, params.height);
|
||||||
|
|
||||||
if (!thresholdByAge || bmi === null) {
|
if (!thresholdByAge || Number.isNaN(bmi)) return 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;
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const companyOfferSchema = z.object({
|
export const companyOfferSchema = z.object({
|
||||||
companyName: z.string({
|
companyName: z.string({
|
||||||
error: 'Company name is required',
|
required_error: 'Company name is required',
|
||||||
}),
|
}),
|
||||||
contactPerson: z.string({
|
contactPerson: z.string({
|
||||||
error: 'Contact person is required',
|
required_error: 'Contact person is required',
|
||||||
}),
|
}),
|
||||||
email: z
|
email: z
|
||||||
.email({
|
.string({
|
||||||
error: 'Invalid email',
|
required_error: 'Email is required',
|
||||||
}),
|
})
|
||||||
|
.email('Invalid email'),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
mdxRs: true,
|
mdxRs: true,
|
||||||
reactCompiler: false,
|
reactCompiler: ENABLE_REACT_COMPILER,
|
||||||
optimizePackageImports: [
|
optimizePackageImports: [
|
||||||
'recharts',
|
'recharts',
|
||||||
'lucide-react',
|
'lucide-react',
|
||||||
|
|||||||
@@ -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,7 +69,6 @@
|
|||||||
"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",
|
||||||
@@ -83,7 +82,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": "^4.1.5"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
|||||||
@@ -21,25 +21,39 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
|
|||||||
export const LineItemSchema = z
|
export const LineItemSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z
|
id: z
|
||||||
.string()
|
.string({
|
||||||
.describe('Unique identifier for the line item. Defined by the Provider.')
|
description:
|
||||||
|
'Unique identifier for the line item. Defined by the Provider.',
|
||||||
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
name: z
|
name: z
|
||||||
.string().describe('Name of the line item. Displayed to the user.')
|
.string({
|
||||||
|
description: 'Name of the line item. Displayed to the user.',
|
||||||
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
description: z
|
description: z
|
||||||
.string().describe('Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
|
.string({
|
||||||
' from the line item. This is useful if you want to provide a more detailed description to the user.')
|
description:
|
||||||
|
'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().describe('Cost of the line item. Displayed to the user.')
|
.number({
|
||||||
|
description: 'Cost of the line item. Displayed to the user.',
|
||||||
|
})
|
||||||
.min(0),
|
.min(0),
|
||||||
type: LineItemTypeSchema,
|
type: LineItemTypeSchema,
|
||||||
unit: z
|
unit: z
|
||||||
.string().describe('Unit of the line item. Displayed to the user. Example "seat" or "GB"')
|
.string({
|
||||||
|
description:
|
||||||
|
'Unit of the line item. Displayed to the user. Example "seat" or "GB"',
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
setupFee: z
|
setupFee: z
|
||||||
.number().describe(`Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`)
|
.number({
|
||||||
|
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
|
||||||
@@ -78,10 +92,14 @@ export const LineItemSchema = z
|
|||||||
export const PlanSchema = z
|
export const PlanSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z
|
id: z
|
||||||
.string().describe('Unique identifier for the plan. Defined by yourself.')
|
.string({
|
||||||
|
description: 'Unique identifier for the plan. Defined by yourself.',
|
||||||
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
name: z
|
name: z
|
||||||
.string().describe('Name of the plan. Displayed to the user.')
|
.string({
|
||||||
|
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(),
|
||||||
@@ -106,7 +124,10 @@ export const PlanSchema = z
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
trialDays: z
|
trialDays: z
|
||||||
.number().describe('Number of days for the trial period. Leave empty for no trial.')
|
.number({
|
||||||
|
description:
|
||||||
|
'Number of days for the trial period. Leave empty for no trial.',
|
||||||
|
})
|
||||||
.positive()
|
.positive()
|
||||||
.optional(),
|
.optional(),
|
||||||
paymentType: PaymentTypeSchema,
|
paymentType: PaymentTypeSchema,
|
||||||
@@ -188,34 +209,54 @@ export const PlanSchema = z
|
|||||||
const ProductSchema = z
|
const ProductSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z
|
id: z
|
||||||
.string().describe('Unique identifier for the product. Defined by th Provider.')
|
.string({
|
||||||
|
description:
|
||||||
|
'Unique identifier for the product. Defined by th Provider.',
|
||||||
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
name: z
|
name: z
|
||||||
.string().describe('Name of the product. Displayed to the user.')
|
.string({
|
||||||
|
description: 'Name of the product. Displayed to the user.',
|
||||||
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
description: z
|
description: z
|
||||||
.string().describe('Description of the product. Displayed to the user.')
|
.string({
|
||||||
|
description: 'Description of the product. Displayed to the user.',
|
||||||
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
currency: z
|
currency: z
|
||||||
.string().describe('Currency code for the product. Displayed to the user.')
|
.string({
|
||||||
|
description: 'Currency code for the product. Displayed to the user.',
|
||||||
|
})
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(3),
|
.max(3),
|
||||||
badge: z
|
badge: z
|
||||||
.string().describe('Badge for the product. Displayed to the user. Example: "Popular"')
|
.string({
|
||||||
|
description:
|
||||||
|
'Badge for the product. Displayed to the user. Example: "Popular"',
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
features: z
|
features: z
|
||||||
.array(
|
.array(
|
||||||
z.string(),
|
z.string({
|
||||||
).describe('Features of the product. Displayed to the user.')
|
description: 'Features of the product. Displayed to the user.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
.nonempty(),
|
.nonempty(),
|
||||||
enableDiscountField: z
|
enableDiscountField: z
|
||||||
.boolean().describe('Enable discount field for the product in the checkout.')
|
.boolean({
|
||||||
|
description: 'Enable discount field for the product in the checkout.',
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
highlighted: z
|
highlighted: z
|
||||||
.boolean().describe('Highlight this product. Displayed to the user.')
|
.boolean({
|
||||||
|
description: 'Highlight this product. Displayed to the user.',
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
hidden: z
|
hidden: z
|
||||||
.boolean().describe('Hide this product from being displayed to users.')
|
.boolean({
|
||||||
|
description: 'Hide this product from being displayed to users.',
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
plans: z.array(PlanSchema),
|
plans: z.array(PlanSchema),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ReportBillingUsageSchema = z.object({
|
export const ReportBillingUsageSchema = z.object({
|
||||||
id: z.string().describe('The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.'),
|
id: z.string({
|
||||||
|
description:
|
||||||
|
'The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.',
|
||||||
|
}),
|
||||||
eventName: z
|
eventName: z
|
||||||
.string()
|
.string({
|
||||||
.describe('The name of the event that triggered the usage')
|
description: 'The name of the event that triggered the usage',
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
usage: z.object({
|
usage: z.object({
|
||||||
quantity: z.number(),
|
quantity: z.number(),
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { z } from 'zod';
|
|||||||
export const UpdateHealthBenefitSchema = z.object({
|
export const UpdateHealthBenefitSchema = z.object({
|
||||||
occurance: z
|
occurance: z
|
||||||
.string({
|
.string({
|
||||||
error: 'Occurance is required',
|
required_error: 'Occurance is required',
|
||||||
})
|
})
|
||||||
.nonempty(),
|
.nonempty(),
|
||||||
amount: z.number({ error: 'Amount is required' }),
|
amount: z.number({ required_error: 'Amount is required' }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ export const MontonioServerEnvSchema = z
|
|||||||
.object({
|
.object({
|
||||||
secretKey: z
|
secretKey: z
|
||||||
.string({
|
.string({
|
||||||
error: `Please provide the variable MONTONIO_SECRET_KEY`,
|
required_error: `Please provide the variable MONTONIO_SECRET_KEY`,
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
apiUrl: z
|
apiUrl: z
|
||||||
.string({
|
.string({
|
||||||
error: `Please provide the variable MONTONIO_API_URL`,
|
required_error: `Please provide the variable MONTONIO_API_URL`,
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ export const StripeServerEnvSchema = z
|
|||||||
.object({
|
.object({
|
||||||
secretKey: z
|
secretKey: z
|
||||||
.string({
|
.string({
|
||||||
error: `Please provide the variable STRIPE_SECRET_KEY`,
|
required_error: `Please provide the variable STRIPE_SECRET_KEY`,
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
webhooksSecret: z
|
webhooksSecret: z
|
||||||
.string({
|
.string({
|
||||||
error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
|
required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { DatabaseWebhookVerifierService } from './database-webhook-verifier.serv
|
|||||||
|
|
||||||
const webhooksSecret = z
|
const webhooksSecret = z
|
||||||
.string({
|
.string({
|
||||||
error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
|
description: `The secret used to verify the webhook signature`,
|
||||||
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
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'
|
|
||||||
|
|||||||
@@ -79,9 +79,15 @@ export function PersonalAccountDropdown({
|
|||||||
}) {
|
}) {
|
||||||
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||||
|
|
||||||
const { name, last_name } = personalAccountData ?? {};
|
const signedInAsLabel = useMemo(() => {
|
||||||
const firstNameLabel = toTitleCase(name) ?? '-';
|
const email = user?.email ?? undefined;
|
||||||
const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
|
const phone = user?.phone ?? undefined;
|
||||||
|
|
||||||
|
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 ?? [];
|
||||||
@@ -95,14 +101,14 @@ export function PersonalAccountDropdown({
|
|||||||
personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin;
|
personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin;
|
||||||
|
|
||||||
return hasAdminRole && hasTotpFactor;
|
return hasAdminRole && hasTotpFactor;
|
||||||
}, [personalAccountData, hasTotpFactor]);
|
}, [user, 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;
|
||||||
}, [personalAccountData, hasTotpFactor]);
|
}, [user, personalAccountData, hasTotpFactor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -122,7 +128,7 @@ export function PersonalAccountDropdown({
|
|||||||
<ProfileAvatar
|
<ProfileAvatar
|
||||||
className={'rounded-md'}
|
className={'rounded-md'}
|
||||||
fallbackClassName={'rounded-md border'}
|
fallbackClassName={'rounded-md border'}
|
||||||
displayName={firstNameLabel}
|
displayName={displayName ?? user?.email ?? ''}
|
||||||
pictureUrl={personalAccountData?.picture_url}
|
pictureUrl={personalAccountData?.picture_url}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -136,7 +142,7 @@ export function PersonalAccountDropdown({
|
|||||||
data-test={'account-dropdown-display-name'}
|
data-test={'account-dropdown-display-name'}
|
||||||
className={'truncate text-sm'}
|
className={'truncate text-sm'}
|
||||||
>
|
>
|
||||||
{firstNameLabel}
|
{toTitleCase(displayName)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,7 +164,7 @@ export function PersonalAccountDropdown({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className={'block truncate'}>{fullNameLabel}</span>
|
<span className={'block truncate'}>{signedInAsLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ 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 {
|
||||||
@@ -47,18 +46,13 @@ 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() {
|
export function MultiFactorAuthFactorsList(props: { userId: string }) {
|
||||||
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={user?.id} />
|
<FactorsTableContainer userId={props.userId} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MultiFactorAuthSetupDialog userId={user?.id} />
|
<MultiFactorAuthSetupDialog userId={props.userId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
'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}`;
|
||||||
|
}
|
||||||
@@ -2,19 +2,18 @@ import { SupabaseClient } from '@supabase/supabase-js';
|
|||||||
|
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
|
import {
|
||||||
|
AnalysisResultDetails,
|
||||||
|
UserAnalysis,
|
||||||
|
} from '../types/accounts';
|
||||||
|
|
||||||
export type AccountWithParams =
|
export type AccountWithParams =
|
||||||
Database['medreport']['Tables']['accounts']['Row'] & {
|
Database['medreport']['Tables']['accounts']['Row'] & {
|
||||||
accountParams:
|
account_params:
|
||||||
| (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,9 +34,7 @@ class AccountsApi {
|
|||||||
const { data, error } = await this.client
|
const { data, error } = await this.client
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select(
|
.select('*, account_params: account_params (weight, height)')
|
||||||
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
|
|
||||||
)
|
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -48,41 +45,6 @@ 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.
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ const SPECIAL_CHARACTERS_REGEX = /[!@#$%^&*()+=[\]{};':"\\|,.<>/?]/;
|
|||||||
* @name CompanyNameSchema
|
* @name CompanyNameSchema
|
||||||
*/
|
*/
|
||||||
export const CompanyNameSchema = z
|
export const CompanyNameSchema = z
|
||||||
.string()
|
.string({
|
||||||
.describe('The name of the company account')
|
description: 'The name of the company account',
|
||||||
|
})
|
||||||
.min(2)
|
.min(2)
|
||||||
.max(50)
|
.max(50)
|
||||||
.refine(
|
.refine(
|
||||||
|
|||||||
@@ -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,12 +88,10 @@ export const OauthProviders: React.FC<{
|
|||||||
queryParams.set('invite_token', props.inviteToken);
|
queryParams.set('invite_token', props.inviteToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// signicat/keycloak will not allow redirect-uri with changing query params
|
const redirectPath = [
|
||||||
const INCLUDE_QUERY_PARAMS = false as boolean;
|
props.paths.callback,
|
||||||
|
queryParams.toString(),
|
||||||
const redirectPath = INCLUDE_QUERY_PARAMS
|
].join('?');
|
||||||
? [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;
|
||||||
@@ -104,7 +102,7 @@ export const OauthProviders: React.FC<{
|
|||||||
redirectTo,
|
redirectTo,
|
||||||
queryParams: props.queryParams,
|
queryParams: props.queryParams,
|
||||||
scopes,
|
scopes,
|
||||||
// skipBrowserRedirect: false,
|
skipBrowserRedirect: false,
|
||||||
},
|
},
|
||||||
} satisfies SignInWithOAuthCredentials;
|
} satisfies SignInWithOAuthCredentials;
|
||||||
|
|
||||||
|
|||||||
@@ -108,9 +108,6 @@ 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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,9 +79,6 @@ 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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,
|
||||||
accountParams:account_params(height,weight)`,
|
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,
|
||||||
accountParams,
|
account_params,
|
||||||
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: accountParams?.height,
|
height: account_params?.[0]?.height,
|
||||||
weight: accountParams?.weight,
|
weight: account_params?.[0]?.weight,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,7 @@ export async function getOrSetCart(countryCode: string) {
|
|||||||
return cart;
|
return cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCart(
|
export async function updateCart({ id, ...data }: HttpTypes.StoreUpdateCart & { id?: string }) {
|
||||||
{ 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) {
|
||||||
@@ -112,13 +109,9 @@ export async function updateCart(
|
|||||||
const fulfillmentCacheTag = await getCacheTag("fulfillment");
|
const fulfillmentCacheTag = await getCacheTag("fulfillment");
|
||||||
revalidateTag(fulfillmentCacheTag);
|
revalidateTag(fulfillmentCacheTag);
|
||||||
|
|
||||||
onSuccess();
|
|
||||||
return cart;
|
return cart;
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch(medusaError);
|
||||||
onError();
|
|
||||||
return medusaError(e);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addToCart({
|
export async function addToCart({
|
||||||
@@ -266,10 +259,7 @@ export async function initiatePaymentSession(
|
|||||||
.catch(medusaError);
|
.catch(medusaError);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyPromotions(
|
export async function applyPromotions(codes: string[]) {
|
||||||
codes: string[],
|
|
||||||
{ onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} },
|
|
||||||
) {
|
|
||||||
const cartId = await getCartId();
|
const cartId = await getCartId();
|
||||||
|
|
||||||
if (!cartId) {
|
if (!cartId) {
|
||||||
@@ -288,13 +278,8 @@ export async function applyPromotions(
|
|||||||
|
|
||||||
const fulfillmentCacheTag = await getCacheTag("fulfillment");
|
const fulfillmentCacheTag = await getCacheTag("fulfillment");
|
||||||
revalidateTag(fulfillmentCacheTag);
|
revalidateTag(fulfillmentCacheTag);
|
||||||
|
|
||||||
onSuccess();
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch(medusaError);
|
||||||
onError();
|
|
||||||
return medusaError(e);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyGiftCard(code: string) {
|
export async function applyGiftCard(code: string) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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,
|
||||||
@@ -126,7 +127,7 @@ export async function login(_currentState: unknown, formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function medusaLogout(countryCode = 'ee') {
|
export async function signout(countryCode: string) {
|
||||||
await sdk.auth.logout()
|
await sdk.auth.logout()
|
||||||
|
|
||||||
await removeAuthToken()
|
await removeAuthToken()
|
||||||
@@ -138,6 +139,8 @@ export async function medusaLogout(countryCode = 'ee') {
|
|||||||
|
|
||||||
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() {
|
||||||
@@ -257,110 +260,62 @@ export const updateCustomerAddress = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function medusaLogin(email: string, password: string) {
|
|
||||||
const token = await sdk.auth.login("customer", "emailpass", { email, password });
|
|
||||||
await setAuthToken(token as string);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await transferCart();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to transfer cart", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = await retrieveCustomer();
|
|
||||||
if (!customer) {
|
|
||||||
throw new Error("Customer not found for active session");
|
|
||||||
}
|
|
||||||
|
|
||||||
return customer.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function medusaRegister({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
name,
|
|
||||||
lastName,
|
|
||||||
}: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
name: string | undefined;
|
|
||||||
lastName: string | undefined;
|
|
||||||
}) {
|
|
||||||
console.info(`Creating new Medusa account for Keycloak user with email=${email}`);
|
|
||||||
|
|
||||||
const registerToken = await sdk.auth.register("customer", "emailpass", { email, password });
|
|
||||||
await setAuthToken(registerToken);
|
|
||||||
|
|
||||||
console.info(`Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`);
|
|
||||||
await sdk.store.customer.create(
|
|
||||||
{ email, first_name: name, last_name: lastName },
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
...(await getAuthHeaders()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function medusaLoginOrRegister(credentials: {
|
export async function medusaLoginOrRegister(credentials: {
|
||||||
email: string
|
email: string
|
||||||
supabaseUserId?: string
|
password?: string
|
||||||
name?: string,
|
}) {
|
||||||
lastName?: string,
|
const { email, password } = credentials;
|
||||||
} & ({ 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 generateDeterministicPassword(email, supabaseUserId);
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await medusaLogin(email, password);
|
const token = await sdk.auth.login("customer", "emailpass", {
|
||||||
} catch (loginError) {
|
email,
|
||||||
console.error("Failed to login customer, attempting to register", loginError);
|
password,
|
||||||
|
});
|
||||||
|
await setAuthToken(token as string);
|
||||||
|
await transferCart();
|
||||||
|
|
||||||
|
const customerCacheTag = await getCacheTag("customers");
|
||||||
|
revalidateTag(customerCacheTag);
|
||||||
|
|
||||||
|
const customer = await retrieveCustomer();
|
||||||
|
if (!customer) {
|
||||||
|
throw new Error("Customer not found");
|
||||||
|
}
|
||||||
|
return customer.id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to login customer, attempting to register", error);
|
||||||
try {
|
try {
|
||||||
await medusaRegister({ email, password, name, lastName });
|
const registerToken = await sdk.auth.register("customer", "emailpass", {
|
||||||
return await medusaLogin(email, password);
|
email: email,
|
||||||
|
password: password,
|
||||||
|
})
|
||||||
|
|
||||||
|
await setAuthToken(registerToken as string);
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(await getAuthHeaders()),
|
||||||
|
};
|
||||||
|
|
||||||
|
await sdk.store.customer.create({ email }, {}, headers);
|
||||||
|
|
||||||
|
const loginToken = await sdk.auth.login("customer", "emailpass", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setAuthToken(loginToken as string);
|
||||||
|
|
||||||
|
const customerCacheTag = await getCacheTag("customers");
|
||||||
|
revalidateTag(customerCacheTag);
|
||||||
|
await transferCart();
|
||||||
|
|
||||||
|
const customer = await retrieveCustomer();
|
||||||
|
if (!customer) {
|
||||||
|
throw new Error("Customer not found");
|
||||||
|
}
|
||||||
|
return customer.id;
|
||||||
} 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!`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { medusaLogout } from "@lib/data/customer"
|
import { signout } 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 medusaLogout(countryCode)
|
await signout(countryCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,22 +17,22 @@ const env = z
|
|||||||
.object({
|
.object({
|
||||||
invitePath: z
|
invitePath: z
|
||||||
.string({
|
.string({
|
||||||
error: 'The property invitePath is required',
|
required_error: 'The property invitePath is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
siteURL: z
|
siteURL: z
|
||||||
.string({
|
.string({
|
||||||
error: 'NEXT_PUBLIC_SITE_URL is required',
|
required_error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
productName: z
|
productName: z
|
||||||
.string({
|
.string({
|
||||||
error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
emailSender: z
|
emailSender: z
|
||||||
.string({
|
.string({
|
||||||
error: 'EMAIL_SENDER is required',
|
required_error: 'EMAIL_SENDER is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class AccountWebhooksService {
|
|||||||
productName: z.string(),
|
productName: z.string(),
|
||||||
fromEmail: z
|
fromEmail: z
|
||||||
.string({
|
.string({
|
||||||
error: 'EMAIL_SENDER is required',
|
required_error: 'EMAIL_SENDER is required',
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ type Config = z.infer<typeof MailerSchema>;
|
|||||||
|
|
||||||
const RESEND_API_KEY = z
|
const RESEND_API_KEY = z
|
||||||
.string({
|
.string({
|
||||||
error: 'Please provide the API key for the Resend API',
|
description: '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() {
|
||||||
|
|||||||
@@ -4,19 +4,25 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const SmtpConfigSchema = z.object({
|
export const SmtpConfigSchema = z.object({
|
||||||
user: z.string({
|
user: z.string({
|
||||||
error: `Please provide the variable EMAIL_USER`,
|
description:
|
||||||
})
|
'This is the email account to send emails from. This is specific to the email provider.',
|
||||||
.describe('This is the email account to send emails from. This is specific to the email provider.'),
|
required_error: `Please provide the variable EMAIL_USER`,
|
||||||
|
}),
|
||||||
pass: z.string({
|
pass: z.string({
|
||||||
error: `Please provide the variable EMAIL_PASSWORD`,
|
description: 'This is the password for the email account',
|
||||||
}).describe('This is the password for the email account'),
|
required_error: `Please provide the variable EMAIL_PASSWORD`,
|
||||||
|
}),
|
||||||
host: z.string({
|
host: z.string({
|
||||||
error: `Please provide the variable EMAIL_HOST`,
|
description: 'This is the SMTP host for the email provider',
|
||||||
}).describe('This is the SMTP host for the email provider'),
|
required_error: `Please provide the variable EMAIL_HOST`,
|
||||||
|
}),
|
||||||
port: z.number({
|
port: z.number({
|
||||||
error: `Please provide the variable EMAIL_PORT`,
|
description:
|
||||||
}).describe('This is the port for the email provider. Normally 587 or 465.'),
|
'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({
|
||||||
error: `Please provide the variable EMAIL_TLS`,
|
description: 'This is whether the connection is secure or not',
|
||||||
}).describe('This is whether the connection is secure or not'),
|
required_error: `Please provide the variable EMAIL_TLS`,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { MonitoringService } from '@kit/monitoring-core';
|
|||||||
|
|
||||||
const apiKey = z
|
const apiKey = z
|
||||||
.string({
|
.string({
|
||||||
error: 'NEXT_PUBLIC_BASELIME_KEY is required',
|
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 {
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { getLogger } from '@kit/shared/logger';
|
|||||||
|
|
||||||
const EMAIL_SENDER = z
|
const EMAIL_SENDER = z
|
||||||
.string({
|
.string({
|
||||||
error: 'EMAIL_SENDER is required',
|
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({
|
||||||
error: 'PRODUCT_NAME is required',
|
required_error: 'PRODUCT_NAME is required',
|
||||||
})
|
})
|
||||||
.min(1)
|
.min(1)
|
||||||
.parse(process.env.NEXT_PUBLIC_PRODUCT_NAME);
|
.parse(process.env.NEXT_PUBLIC_PRODUCT_NAME);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ 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,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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;
|
||||||
@@ -58,7 +57,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(pathsConfig.app.cart);
|
router.push('/home/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);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,30 +6,32 @@ const AppConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
name: z
|
name: z
|
||||||
.string({
|
.string({
|
||||||
error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`,
|
description: `This is the name of your SaaS. Ex. "Makerkit"`,
|
||||||
|
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({
|
||||||
error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`,
|
description: `This is the default title tag of your SaaS.`,
|
||||||
|
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({
|
||||||
error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`,
|
description: `This is the default description of your SaaS.`,
|
||||||
})
|
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({
|
||||||
error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`,
|
description: `This is the default locale of your SaaS.`,
|
||||||
|
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(),
|
||||||
|
|||||||
@@ -6,14 +6,22 @@ const providers: z.ZodType<Provider> = getProviders();
|
|||||||
|
|
||||||
const AuthConfigSchema = z.object({
|
const AuthConfigSchema = z.object({
|
||||||
captchaTokenSiteKey: z
|
captchaTokenSiteKey: z
|
||||||
.string().describe('The reCAPTCHA site key.')
|
.string({
|
||||||
|
description: 'The reCAPTCHA site key.',
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
displayTermsCheckbox: z
|
displayTermsCheckbox: z
|
||||||
.boolean().describe('Whether to display the terms checkbox during sign-up.')
|
.boolean({
|
||||||
|
description: 'Whether to display the terms checkbox during sign-up.',
|
||||||
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
providers: z.object({
|
providers: z.object({
|
||||||
password: z.boolean().describe('Enable password authentication.'),
|
password: z.boolean({
|
||||||
magicLink: z.boolean().describe('Enable magic link authentication.'),
|
description: 'Enable password authentication.',
|
||||||
|
}),
|
||||||
|
magicLink: z.boolean({
|
||||||
|
description: 'Enable magic link authentication.',
|
||||||
|
}),
|
||||||
oAuth: providers.array(),
|
oAuth: providers.array(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,56 +4,56 @@ type LanguagePriority = 'user' | 'application';
|
|||||||
|
|
||||||
const FeatureFlagsSchema = z.object({
|
const FeatureFlagsSchema = z.object({
|
||||||
enableThemeToggle: z.boolean({
|
enableThemeToggle: z.boolean({
|
||||||
error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
|
description: 'Enable theme toggle in the user interface.',
|
||||||
})
|
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
|
||||||
.describe( 'Enable theme toggle in the user interface.'),
|
}),
|
||||||
enableAccountDeletion: z.boolean({
|
enableAccountDeletion: z.boolean({
|
||||||
error:
|
description: 'Enable personal account deletion.',
|
||||||
|
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({
|
||||||
error:
|
description: 'Enable team deletion.',
|
||||||
|
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({
|
||||||
error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
|
description: 'Enable team accounts.',
|
||||||
})
|
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
|
||||||
.describe('Enable team accounts.'),
|
}),
|
||||||
enableTeamCreation: z.boolean({
|
enableTeamCreation: z.boolean({
|
||||||
error:
|
description: 'Enable team creation.',
|
||||||
|
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({
|
||||||
error:
|
description: 'Enable personal account billing.',
|
||||||
|
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({
|
||||||
error:
|
description: 'Enable team account billing.',
|
||||||
|
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'], {
|
||||||
error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY',
|
required_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({
|
||||||
error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
|
description: 'Enable notifications functionality',
|
||||||
})
|
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
|
||||||
.describe('Enable notifications functionality'),
|
}),
|
||||||
realtimeNotifications: z.boolean({
|
realtimeNotifications: z.boolean({
|
||||||
error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
|
description: 'Enable realtime for the notifications functionality',
|
||||||
})
|
required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
|
||||||
.describe('Enable realtime for the notifications functionality'),
|
}),
|
||||||
enableVersionUpdater: z.boolean({
|
enableVersionUpdater: z.boolean({
|
||||||
error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER',
|
description: '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({
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const PathsSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
app: z.object({
|
app: z.object({
|
||||||
home: z.string().min(1),
|
home: z.string().min(1),
|
||||||
cart: z.string().min(1),
|
|
||||||
selectPackage: z.string().min(1),
|
selectPackage: z.string().min(1),
|
||||||
booking: z.string().min(1),
|
booking: z.string().min(1),
|
||||||
bookingHandle: z.string().min(1),
|
bookingHandle: z.string().min(1),
|
||||||
@@ -24,8 +23,6 @@ const PathsSchema = z.object({
|
|||||||
orderAnalysis: z.string().min(1),
|
orderAnalysis: z.string().min(1),
|
||||||
orderHealthAnalysis: z.string().min(1),
|
orderHealthAnalysis: z.string().min(1),
|
||||||
personalAccountSettings: z.string().min(1),
|
personalAccountSettings: z.string().min(1),
|
||||||
personalAccountPreferences: z.string().min(1),
|
|
||||||
personalAccountSecurity: z.string().min(1),
|
|
||||||
personalAccountBilling: z.string().min(1),
|
personalAccountBilling: z.string().min(1),
|
||||||
personalAccountBillingReturn: z.string().min(1),
|
personalAccountBillingReturn: z.string().min(1),
|
||||||
accountHome: z.string().min(1),
|
accountHome: z.string().min(1),
|
||||||
@@ -57,10 +54,7 @@ const pathsConfig = PathsSchema.parse({
|
|||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
home: '/home',
|
home: '/home',
|
||||||
cart: '/home/cart',
|
|
||||||
personalAccountSettings: '/home/settings',
|
personalAccountSettings: '/home/settings',
|
||||||
personalAccountPreferences: '/home/settings/preferences',
|
|
||||||
personalAccountSecurity: '/home/settings/security',
|
|
||||||
personalAccountBilling: '/home/billing',
|
personalAccountBilling: '/home/billing',
|
||||||
personalAccountBillingReturn: '/home/billing/return',
|
personalAccountBillingReturn: '/home/billing/return',
|
||||||
accountHome: '/home/[account]',
|
accountHome: '/home/[account]',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
|
TestTube2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user