Merge pull request #38 from MR-medreport/MED-48

feat(MED-48 MED-100): update products -> cart -> montonio -> orders flow, send email
This commit is contained in:
2025-07-24 10:26:34 +03:00
committed by GitHub
51 changed files with 2570 additions and 327 deletions

4
.env
View File

@@ -5,7 +5,7 @@
# TO OVERRIDE THESE VARIABLES IN A SPECIFIC ENVIRONMENT, PLEASE ADD THEM TO THE SPECIFIC ENVIRONMENT FILE (e.g. .env.development, .env.production)
# SITE
NEXT_PUBLIC_SITE_URL=https://localhost:3000
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_PRODUCT_NAME=MedReport
NEXT_PUBLIC_SITE_TITLE="MedReport"
NEXT_PUBLIC_SITE_DESCRIPTION="MedReport."
@@ -19,7 +19,7 @@ NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
# BILLING
NEXT_PUBLIC_BILLING_PROVIDER=stripe
NEXT_PUBLIC_BILLING_PROVIDER=montonio
# CMS
CMS_CLIENT=keystatic

View File

@@ -58,12 +58,6 @@ export const POST = enhanceRouteHandler(
algorithms: ['HS256'],
}) as MontonioOrderToken;
const activeCartId = request.cookies.get('_medusa_cart_id')?.value;
const [, cartId] = decoded.merchantReferenceDisplay.split(':');
if (cartId !== activeCartId) {
throw new Error('Invalid cart id');
}
logger.info(
{
name: namespace,

View File

@@ -1,23 +0,0 @@
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { MontonioCheckoutCallback } from '../../../../_components/cart/montonio-checkout-callback';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { Trans } from '@kit/ui/trans';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('cart:montonioCallback.title'),
};
}
export default async function MontonioCheckoutCallbackPage() {
return (
<div className={'flex h-full flex-1 flex-col'}>
<PageHeader title={<Trans i18nKey="cart:montonioCallback.title" />} />
<PageBody>
<MontonioCheckoutCallback />
</PageBody>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import jwt from 'jsonwebtoken';
import { z } from "zod";
import { MontonioOrderToken } from "@/app/home/(user)/_components/cart/types";
import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-account";
import { listProductTypes } from "@lib/data/products";
import { placeOrder } from "@lib/data/cart";
import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
const emailSender = process.env.EMAIL_SENDER;
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!;
const env = z
.object({
emailSender: z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1),
siteUrl: z
.string({
required_error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
})
.parse({
emailSender,
siteUrl,
});
const sendEmail = async ({ email, analysisPackageName, personName, partnerLocationName, language }: { email: string, analysisPackageName: string, personName: string, partnerLocationName: string, language: string }) => {
try {
const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const { html, subject } = await renderSynlabAnalysisPackageEmail({
analysisPackageName,
personName,
partnerLocationName,
language,
});
await mailer
.sendEmail({
from: env.emailSender,
to: email,
subject,
html,
})
.catch((error) => {
throw new Error(`Failed to send email, message=${error}`);
});
} catch (error) {
throw new Error(`Failed to send email, message=${error}`);
}
}
const handleOrderToken = async (orderToken: string) => {
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
const decoded = jwt.verify(orderToken, secretKey, {
algorithms: ['HS256'],
}) as MontonioOrderToken;
if (decoded.paymentStatus !== 'PAID') {
return null;
}
try {
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
if (!cartId) {
throw new Error("Cart ID not found");
}
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
const order = await placeOrder(cartId, { revalidateCacheTags: true });
const analysisPackageOrderItem = order.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
return {
email: order.email,
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
analysisPackageName: analysisPackageOrderItem?.title ?? '',
};
} catch (error) {
throw new Error(`Failed to place order, message=${error}`);
}
}
export async function GET(request: Request) {
const { language } = await createI18nServerInstance();
const baseUrl = new URL(env.siteUrl.replace("localhost", "webhook.site"));
try {
const orderToken = new URL(request.url).searchParams.get('order-token');
if (!orderToken) {
throw new Error("Order token is missing");
}
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error("Account not found in context");
}
const orderResult = await handleOrderToken(orderToken);
if (!orderResult) {
throw new Error("Order result is missing");
}
const { email, partnerLocationName, analysisPackageName } = orderResult;
const personName = account.name;
if (email && analysisPackageName) {
await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language });
} else {
console.error("Missing email or analysisPackageName", orderResult);
}
return Response.redirect(new URL('/home/order', baseUrl))
} catch (error) {
console.error("Failed to place order", error);
return Response.redirect(new URL('/home/cart/montonio-callback/error', baseUrl));
}
}

View File

@@ -0,0 +1,47 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { Trans } from '@kit/ui/trans';
import { Alert, AlertDescription } from '@kit/ui/shadcn/alert';
import { AlertTitle } from '@kit/ui/shadcn/alert';
import { Button } from '@kit/ui/button';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('cart:montonioCallback.title'),
};
}
export default async function MontonioCheckoutCallbackErrorPage() {
return (
<div className={'flex h-full flex-1 flex-col'}>
<PageHeader title={<Trans i18nKey="cart:montonioCallback.title" />} />
<PageBody>
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'checkout.error.title'} />
</AlertTitle>
<AlertDescription>
<p>
<Trans i18nKey={'checkout.error.description'} />
</p>
</AlertDescription>
</Alert>
<div className={'flex'}>
<Button asChild>
<Link href={'/home'}>
<Trans i18nKey={'checkout.goToDashboard'} />
</Link>
</Button>
</div>
</div>
</PageBody>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { notFound } from 'next/navigation';
import { retrieveCart } from '~/medusa/lib/data/cart';
import Cart from '../../_components/cart';
import { listCollections } from '@lib/data';
import { listProductTypes } from '@lib/data';
import CartTimer from '../../_components/cart/cart-timer';
import { Trans } from '@kit/ui/trans';
@@ -23,15 +23,12 @@ export default async function CartPage() {
return notFound();
});
const { collections } = await listCollections({
limit: "100",
});
const analysisPackagesCollection = collections.find(({ handle }) => handle === 'analysis-packages');
const analysisPackages = analysisPackagesCollection && cart?.items
? cart.items.filter((item) => item.product?.collection_id === analysisPackagesCollection.id)
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
const analysisPackages = analysisPackagesType && cart?.items
? cart.items.filter((item) => item.product?.type_id === analysisPackagesType.id)
: [];
const otherItems = cart?.items?.filter((item) => item.product?.collection_id !== analysisPackagesCollection?.id) ?? [];
const otherItems = cart?.items?.filter((item) => item.product?.type_id !== analysisPackagesType?.id) ?? [];
const otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
const item = otherItemsSorted[0];

View File

@@ -8,6 +8,8 @@ import { z } from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { StoreCart } from '@medusajs/types';
import { retrieveCart } from '@lib/data';
import { AppLogo } from '~/components/app-logo';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
@@ -44,7 +46,7 @@ function SidebarLayout({ children }: React.PropsWithChildren) {
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} />
<MobileNavigation workspace={workspace} cart={null} />
</PageMobileNavigation>
{children}
@@ -66,7 +68,7 @@ function HeaderLayout({ children }: React.PropsWithChildren) {
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} />
<MobileNavigation workspace={workspace} cart={cart} />
</PageMobileNavigation>
<SidebarProvider defaultOpen>
@@ -84,14 +86,16 @@ function HeaderLayout({ children }: React.PropsWithChildren) {
function MobileNavigation({
workspace,
cart,
}: {
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
cart: StoreCart | null;
}) {
return (
<>
<AppLogo />
<HomeMobileNavigation workspace={workspace} />
<HomeMobileNavigation workspace={workspace} cart={cart} />
</>
);
}

View File

@@ -0,0 +1,28 @@
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
return {
title: t('order-analysis:title'),
};
};
async function OrderAnalysisPage() {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'order-analysis:title'} />}
description={<Trans i18nKey={'order-analysis:description'} />}
/>
<PageBody>
</PageBody>
</>
);
}
export default withI18n(OrderAnalysisPage);

View File

@@ -0,0 +1,28 @@
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
return {
title: t('order-health-analysis:title'),
};
};
async function OrderHealthAnalysisPage() {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'order-health-analysis:title'} />}
description={<Trans i18nKey={'order-health-analysis:description'} />}
/>
<PageBody>
</PageBody>
</>
);
}
export default withI18n(OrderHealthAnalysisPage);

View File

@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import { retrieveOrder } from '~/medusa/lib/data/orders';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import OrderCompleted from '@/app/home/(user)/_components/order/order-completed';
import { withI18n } from '~/lib/i18n/with-i18n';
type Props = {
params: Promise<{ orderId: string }>;
@@ -16,7 +17,7 @@ export async function generateMetadata() {
};
}
export default async function OrderConfirmedPage(props: Props) {
async function OrderConfirmedPage(props: Props) {
const params = await props.params;
const order = await retrieveOrder(params.orderId).catch(() => null);
@@ -26,3 +27,5 @@ export default async function OrderConfirmedPage(props: Props) {
return <OrderCompleted order={order} />;
}
export default withI18n(OrderConfirmedPage);

View File

@@ -0,0 +1,49 @@
import { redirect } from 'next/navigation';
import { listOrders } from '~/medusa/lib/data/orders';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { listProductTypes, retrieveCustomer } from '@lib/data';
import { PageBody } from '@kit/ui/makerkit/page';
import pathsConfig from '~/config/paths.config';
import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import OrdersTable from '../../_components/orders/orders-table';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('orders:title'),
};
}
async function OrdersPage() {
const customer = await retrieveCustomer();
const orders = await listOrders().catch(() => null);
const { productTypes } = await listProductTypes();
if (!customer || !orders || !productTypes) {
redirect(pathsConfig.auth.signIn);
}
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
const analysisPackageOrders = orders.flatMap(({ id, items, payment_status, fulfillment_status }) => items
?.filter((item) => item.product_type_id === analysisPackagesType?.id)
.map((item) => ({ item, orderId: id, orderStatus: `${payment_status}/${fulfillment_status}` }))
|| []);
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'orders:title'} />}
description={<Trans i18nKey={'orders:description'} />}
/>
<PageBody>
<OrdersTable orderItems={analysisPackageOrders} />
</PageBody>
</>
);
}
export default withI18n(OrdersPage);

View File

@@ -37,7 +37,7 @@ async function UserHomePage() {
}
/>
<PageBody>
<Dashboard />
<Dashboard account={account} />
</PageBody>
</>
);

View File

@@ -0,0 +1,103 @@
"use client"
import { toast } from 'sonner';
import { useForm } from "react-hook-form";
import { z } from "zod";
import { updateLineItem } from "@lib/data/cart"
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
import { Form } from "@kit/ui/form";
import { Trans } from '@kit/ui/trans';
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
const AnalysisLocationSchema = z.object({
locationId: z.string().min(1),
});
const MOCK_LOCATIONS: { id: string, name: string }[] = [
{ id: "synlab-tallinn-1", name: "SYNLAB - Tallinn" },
{ id: "synlab-tartu-1", name: "SYNLAB - Tartu" },
{ id: "synlab-parnu-1", name: "SYNLAB - Pärnu" },
]
export default function AnalysisLocation({ cart, analysisPackages }: { cart: StoreCart, analysisPackages: StoreCartLineItem[] }) {
const { t } = useTranslation('cart');
const form = useForm<z.infer<typeof AnalysisLocationSchema>>({
defaultValues: {
locationId: cart.metadata?.partner_location_id as string ?? '',
},
resolver: zodResolver(AnalysisLocationSchema),
});
const onSubmit = async ({ locationId }: z.infer<typeof AnalysisLocationSchema>) => {
const promise = Promise.all(analysisPackages.map(async ({ id, quantity }) => {
await updateLineItem({
lineId: id,
quantity,
metadata: {
partner_location_name: MOCK_LOCATIONS.find((location) => location.id === locationId)?.name ?? '',
partner_location_id: locationId,
},
});
}));
toast.promise(promise, {
success: t(`cart:items.analysisLocation.success`),
loading: t(`cart:items.analysisLocation.loading`),
error: t(`cart:items.analysisLocation.error`),
});
}
return (
<div className="w-full bg-white flex flex-col txt-medium">
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => onSubmit(data))}
className="w-full mb-2 flex gap-x-2"
>
<Select
value={form.watch('locationId')}
onValueChange={(value) => {
form.setValue('locationId', value, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
return onSubmit(form.getValues());
}}
>
<SelectTrigger>
<SelectValue placeholder={t('cart:locations.locationSelect')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('cart:locations.locationSelect')}</SelectLabel>
{MOCK_LOCATIONS.map((location) => (
<SelectItem key={location.id} value={location.id}>{location.name}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</form>
</Form>
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:locations.description'} />
</p>
</div>
)
}

View File

@@ -1,5 +1,7 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
import CartItems from "./cart-items"
import { Trans } from '@kit/ui/trans';
@@ -10,11 +12,11 @@ import {
CardHeader,
} from '@kit/ui/card';
import DiscountCode from "./discount-code";
import { useRouter } from "next/navigation";
import { initiatePaymentSession } from "@lib/data/cart";
import { formatCurrency } from "@/packages/shared/src/utils";
import { useTranslation } from "react-i18next";
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
import AnalysisLocation from "./analysis-location";
const IS_DISCOUNT_SHOWN = false as boolean;
@@ -27,9 +29,10 @@ export default function Cart({
analysisPackages: StoreCartLineItem[];
otherItems: StoreCartLineItem[];
}) {
const router = useRouter();
const { i18n: { language } } = useTranslation();
const [isInitiatingSession, setIsInitiatingSession] = useState(false);
const items = cart?.items ?? [];
if (!cart || items.length === 0) {
@@ -49,23 +52,31 @@ export default function Cart({
);
}
async function handlePayment() {
async function initiatePayment() {
setIsInitiatingSession(true);
const response = await initiatePaymentSession(cart!, {
provider_id: 'pp_system_default',
provider_id: 'pp_montonio_montonio',
});
if (response.payment_collection) {
const url = await handleNavigateToPayment({ language });
router.push(url);
const { payment_sessions } = response.payment_collection;
const paymentSessionId = payment_sessions![0]!.id;
const url = await handleNavigateToPayment({ language, paymentSessionId });
window.location.href = url;
} else {
setIsInitiatingSession(false);
}
}
const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0;
const isLocationsShown = analysisPackages.length > 0;
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
<div className="flex flex-col bg-white gap-y-6">
<CartItems cart={cart} items={analysisPackages} productColumnLabelKey="cart:items.analysisPackages.productColumnLabel" />
<CartItems cart={cart} items={otherItems} productColumnLabelKey="cart:items.services.productColumnLabel" />
</div>
{Array.isArray(cart.items) && cart.items.length > 0 && (
{hasCartItems && (
<div className="flex justify-end gap-x-4 px-6 py-4">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm">
@@ -99,10 +110,26 @@ export default function Cart({
</CardContent>
</Card>
)}
{isLocationsShown && (
<Card
className="flex flex-col justify-between w-1/2"
>
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:locations.title" />
</h5>
</CardHeader>
<CardContent>
<AnalysisLocation cart={{ ...cart }} analysisPackages={analysisPackages} />
</CardContent>
</Card>
)}
</div>
<div>
<Button className="h-10" onClick={handlePayment}>
<Button className="h-10" onClick={initiatePayment} disabled={isInitiatingSession}>
{isInitiatingSession && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>

View File

@@ -1,101 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
import { placeOrder } from "@lib/data/cart"
import Link from 'next/link';
import GlobalLoader from '../../loading';
enum Status {
LOADING = 'LOADING',
ERROR = 'ERROR',
}
export function MontonioCheckoutCallback() {
const router = useRouter();
const [status, setStatus] = useState<Status>(Status.LOADING);
const [isFinalized, setIsFinalized] = useState(false);
const searchParams = useSearchParams();
useEffect(() => {
if (isFinalized) {
return;
}
const token = searchParams.get('order-token');
if (!token) {
router.push('/home/cart');
return;
}
async function verifyToken() {
setStatus(Status.LOADING);
try {
const response = await fetch('/api/montonio/verify-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
});
setIsFinalized(true);
if (!response.ok) {
const body = await response.json();
throw new Error(body.error ?? 'Failed to verify payment status.');
}
const body = await response.json();
const paymentStatus = body.status as string;
if (paymentStatus === 'PAID') {
try {
await placeOrder();
} catch (e) {
console.error("Error placing order", e);
router.push('/home/cart');
}
} else {
throw new Error('Payment failed or pending');
}
} catch (e) {
console.error("Error verifying token", e);
setStatus(Status.ERROR);
}
}
void verifyToken();
}, [searchParams, isFinalized]);
if (status === Status.ERROR) {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'checkout.error.title'} />
</AlertTitle>
<AlertDescription>
<p>
<Trans i18nKey={'checkout.error.description'} />
</p>
</AlertDescription>
</Alert>
<div className={'flex'}>
<Button asChild>
<Link href={'/home'}>
<Trans i18nKey={'checkout.goToDashboard'} />
</Link>
</Button>
</div>
</div>
);
}
return <GlobalLoader />;
}

View File

@@ -0,0 +1,22 @@
export interface MontonioOrderToken {
uuid: string;
accessKey: string;
merchantReference: string;
merchantReferenceDisplay: string;
paymentStatus:
| 'PAID'
| 'FAILED'
| 'CANCELLED'
| 'PENDING'
| 'EXPIRED'
| 'REFUNDED';
paymentMethod: string;
grandTotal: number;
currency: string;
senderIban?: string;
senderName?: string;
paymentProviderName?: string;
paymentLinkUuid: string;
iat: number;
exp: number;
}

View File

@@ -15,6 +15,7 @@ import {
TrendingUp,
User,
} from 'lucide-react';
import Isikukood from 'isikukood';
import { Button } from '@kit/ui/button';
import {
@@ -27,29 +28,40 @@ import {
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import type { AccountWithParams } from '@/packages/features/accounts/src/server/api';
const dummyCards = [
const cards = ({
gender,
age,
height,
weight,
}: {
gender?: string;
age?: number;
height?: number | null;
weight?: number | null;
}) => [
{
title: 'dashboard:gender',
description: 'dashboard:male',
description: gender ?? 'dashboard:male',
icon: <User />,
iconBg: 'bg-success',
},
{
title: 'dashboard:age',
description: '43',
description: age ? `${age}` : '-',
icon: <Clock9 />,
iconBg: 'bg-success',
},
{
title: 'dashboard:height',
description: '183',
description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success',
},
{
title: 'dashboard:weight',
description: '92kg',
description: weight ? `${weight}kg` : '-',
icon: <Scale />,
iconBg: 'bg-warning',
},
@@ -128,11 +140,31 @@ const dummyRecommendations = [
},
];
export default function Dashboard() {
const getPersonParameters = (personalCode: string) => {
try {
const person = new Isikukood(personalCode);
return {
gender: person.getGender(),
age: person.getAge(),
};
} catch (error) {
console.error(error);
return null;
}
};
export default function Dashboard({ account }: { account: AccountWithParams }) {
const params = getPersonParameters(account.personal_code!);
return (
<>
<div className="grid auto-rows-fr grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
{dummyCards.map(
{cards({
gender: params?.gender,
age: params?.age,
height: account.account_params?.height,
weight: account.account_params?.weight,
}).map(
({
title,
description,

View File

@@ -46,7 +46,7 @@ export async function HomeMenuNavigation(props: { workspace: UserWorkspace, cart
<Link href='/home/cart'>
<Button variant="ghost" className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' >
<ShoppingCart className="stroke-[1.5px]" />
<Trans i18nKey="common:shoppingCart" /> ({hasCartItems ? cartItemsCount : 0})
<Trans i18nKey="common:shoppingCartCount" values={{ count: cartItemsCount }} />
</Button>
</Link>
<UserNotifications userId={user.id} />

View File

@@ -2,7 +2,7 @@
import Link from 'next/link';
import { LogOut, Menu } from 'lucide-react';
import { LogOut, Menu, ShoppingCart } from 'lucide-react';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
@@ -16,6 +16,7 @@ import {
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { StoreCart } from '@medusajs/types';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
@@ -24,7 +25,7 @@ import { personalAccountNavigationConfig } from '~/config/personal-account-navig
import { HomeAccountSelector } from '../_components/home-account-selector';
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
export function HomeMobileNavigation(props: { workspace: UserWorkspace, cart: StoreCart | null }) {
const signOut = useSignOut();
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
@@ -46,6 +47,10 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
}
});
const cartItemsCount = props.cart?.items?.length ?? 0;
const hasCartItems = cartItemsCount > 0;
return (
<DropdownMenu>
<DropdownMenuTrigger>
@@ -69,6 +74,18 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
<DropdownMenuSeparator />
</If>
<If condition={props.cart && hasCartItems}>
<DropdownMenuGroup>
<DropdownLink
path="/home/cart"
label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartItemsCount }}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -83,6 +100,7 @@ function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
labelOptions?: Record<string, any>;
Icon: React.ReactNode;
}>,
) {
@@ -95,7 +113,7 @@ function DropdownLink(
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
<Trans i18nKey={props.label} defaults={props.label} values={props.labelOptions} />
</span>
</Link>
</DropdownMenuItem>

View File

@@ -22,7 +22,7 @@ export default function OrderItem({ item, currencyCode }: {
className="txt-medium-plus text-ui-fg-base"
data-testid="product-name"
>
{item.product_title}
{item.product_title}{` (${item.metadata?.partner_location_name ?? "-"})`}
</span>
<LineItemOptions variant={item.variant} data-testid="product-variant" />
</TableCell>

View File

@@ -0,0 +1,44 @@
import {
TableCell,
TableRow,
} from '@kit/ui/table';
import { Eye } from "lucide-react";
import Link from "next/link";
import { formatDate } from "date-fns";
import { IAnalysisPackageOrder } from "./types";
export default function OrdersItem({ orderItem }: {
orderItem: IAnalysisPackageOrder,
}) {
return (
<TableRow className="w-full">
<TableCell className="text-left w-[100%] px-6">
<p className="txt-medium-plus text-ui-fg-base">
{orderItem.item.product_title}
</p>
</TableCell>
<TableCell className="px-6 whitespace-nowrap">
{formatDate(orderItem.item.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell>
{orderItem.orderStatus && (
<TableCell className="px-6">
{orderItem.orderStatus}
</TableCell>
)}
<TableCell className="text-right px-6">
<span className="flex gap-x-1 justify-end w-[60px]">
<Link href={`/home/analysis-results`} className="flex items-center justify-between text-small-regular">
<button
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
>
<Eye />
</button>
</Link>
</span>
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,46 @@
import { Trans } from '@kit/ui/trans';
import {
Table,
TableBody,
TableHead,
TableRow,
TableHeader,
} from '@kit/ui/table';
import OrdersItem from "./orders-item";
import { IAnalysisPackageOrder } from "./types";
const IS_SHOWN_ORDER_STATUS = true as boolean;
export default function OrdersTable({ orderItems }: {
orderItems: IAnalysisPackageOrder[];
}) {
if (!orderItems || orderItems.length === 0) {
return null;
}
return (
<Table className="rounded-lg border border-separate">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-6">
<Trans i18nKey="orders:table.analysisPackage" />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="orders:table.createdAt" />
</TableHead>
{IS_SHOWN_ORDER_STATUS && (
<TableHead className="px-6">
</TableHead>
)}
<TableHead className="px-6">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orderItems
.sort((a, b) => (a.item.created_at ?? "") > (b.item.created_at ?? "") ? -1 : 1)
.map((orderItem) => (<OrdersItem key={orderItem.item.id} orderItem={orderItem} />))}
</TableBody>
</Table>
)
}

View File

@@ -0,0 +1,7 @@
import { StoreOrderLineItem } from "@medusajs/types";
export interface IAnalysisPackageOrder {
item: StoreOrderLineItem;
orderId: string;
orderStatus: string;
}

View File

@@ -1,6 +1,6 @@
import { cache } from 'react';
import { listCollections, listProducts, listRegions } from "@lib/data";
import { listProductTypes, listProducts, listRegions } from "@lib/data";
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
@@ -10,26 +10,24 @@ async function countryCodesLoader() {
}
export const loadCountryCodes = cache(countryCodesLoader);
async function collectionsLoader() {
const { collections } = await listCollections({
fields: 'id, handle',
});
return collections ?? [];
async function productTypesLoader() {
const { productTypes } = await listProductTypes();
return productTypes ?? [];
}
export const loadCollections = cache(collectionsLoader);
export const loadProductTypes = cache(productTypesLoader);
async function analysisPackagesLoader() {
const [countryCodes, collections] = await Promise.all([loadCountryCodes(), loadCollections()]);
const [countryCodes, productTypes] = await Promise.all([loadCountryCodes(), loadProductTypes()]);
const countryCode = countryCodes[0]!;
const collection = collections.find(({ handle }) => handle === 'analysis-packages');
if (!collection) {
const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
if (!productType) {
return { analysisPackages: [], countryCode };
}
const { response } = await listProducts({
countryCode,
queryParams: { limit: 100, collection_id: collection?.id },
queryParams: { limit: 100, "type_id[0]": productType.id },
});
return { analysisPackages: response.products, countryCode };
}

View File

@@ -59,11 +59,10 @@ const pathsConfig = PathsSchema.parse({
selectPackage: '/select-package',
booking: '/home/booking',
orderAnalysisPackage: '/home/order-analysis-package',
// these routes are added as placeholders and can be changed when the pages are added
myOrders: '/my-orders',
myOrders: '/home/order',
analysisResults: '/home/analysis-results',
orderAnalysis: '/order-analysis',
orderHealthAnalysis: '/order-health-analysis',
orderAnalysis: '/home/order-analysis',
orderHealthAnalysis: '/home/order-health-analysis',
},
} satisfies z.infer<typeof PathsSchema>);

View File

@@ -37,6 +37,7 @@ export const defaultI18nNamespaces = [
'booking',
'order-analysis-package',
'cart',
'orders',
];
/**

View File

@@ -1,13 +1,34 @@
'use server';
import { z } from 'zod';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
import { headers } from 'next/headers';
import { requireUserInServerComponent } from '../server/require-user-in-server-component';
const medusaBackendUrl = process.env.MEDUSA_BACKEND_URL!;
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!;
const env = z
.object({
medusaBackendUrl: z
.string({
required_error: 'MEDUSA_BACKEND_URL is required',
})
.min(1),
siteUrl: z
.string({
required_error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
})
.parse({
medusaBackendUrl,
siteUrl,
});
export async function handleAddToCart({
selectedVariant,
countryCode,
@@ -46,7 +67,7 @@ export async function handleAddToCart({
return cart;
}
export async function handleNavigateToPayment({ language }: { language: string }) {
export async function handleNavigateToPayment({ language, paymentSessionId }: { language: string, paymentSessionId: string }) {
const supabase = getSupabaseServerClient();
const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount()
@@ -59,21 +80,14 @@ export async function handleNavigateToPayment({ language }: { language: string }
throw new Error("No cart found");
}
const headersList = await headers();
const host = "webhook.site:3000";
const proto = "http";
// const host = headersList.get('host');
// const proto = headersList.get('x-forwarded-proto') ?? 'http';
const publicUrl = `${proto}://${host}`;
const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({
notificationUrl: `${publicUrl}/api/billing/webhook`,
returnUrl: `${publicUrl}/home/cart/montonio-callback`,
notificationUrl: `${env.medusaBackendUrl}/api/billing/webhook`,
returnUrl: `${env.siteUrl}/home/cart/montonio-callback`,
amount: cart.total,
currency: cart.currency_code.toUpperCase(),
description: `Order from Medreport`,
locale: language,
merchantReference: `${account.id}:${cart.id}:${Date.now()}`,
merchantReference: `${account.id}:${paymentSessionId}:${cart.id}`,
});
const { error } = await supabase
@@ -104,12 +118,6 @@ export async function handleLineItemTimeout({
throw new Error('Account not found');
}
if (lineItem.updated_at) {
const updatedAt = new Date(lineItem.updated_at);
const now = new Date();
const diff = now.getTime() - updatedAt.getTime();
}
await deleteLineItem(lineItem.id);
const { error } = await supabase

View File

@@ -6,6 +6,7 @@ import { CsrfError, createCsrfProtect } from '@edge-csrf/nextjs';
import { isSuperAdmin } from '@kit/admin';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { createMiddlewareClient } from '@kit/supabase/middleware-client';
import { middleware as medusaMiddleware } from "~/medusa/middleware";
import appConfig from '~/config/app.config';
import pathsConfig from '~/config/paths.config';
@@ -53,6 +54,8 @@ export async function middleware(request: NextRequest) {
csrfResponse.headers.set('x-action-path', request.nextUrl.pathname);
}
await medusaMiddleware(request as any);
// if no pattern handler returned a response,
// return the session response
return csrfResponse;

View File

@@ -68,6 +68,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fast-xml-parser": "^5.2.5",
"isikukood": "3.1.7",
"jsonwebtoken": "9.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.510.0",

View File

@@ -30,7 +30,7 @@ export class MontonioOrderHandlerService {
locale: string;
merchantReference: string;
}) {
const token = jwt.sign({
const params = {
accessKey,
description,
currency,
@@ -38,12 +38,13 @@ export class MontonioOrderHandlerService {
locale,
// 15 minutes
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
notificationUrl,
returnUrl,
notificationUrl: notificationUrl.replace("localhost", "webhook.site"),
returnUrl: returnUrl.replace("localhost", "webhook.site"),
askAdditionalInfo: false,
merchantReference,
type: "one_time",
}, secretKey, {
};
const token = jwt.sign(params, secretKey, {
algorithm: "HS256",
expiresIn: "10m",
});

View File

@@ -0,0 +1,22 @@
import { TFunction } from "i18next";
import { Text } from "@react-email/components";
import { EmailFooter } from "./footer";
export default function CommonFooter({ t }: { t: TFunction }) {
const namespace = 'common';
const lines = [
t(`${namespace}:footer.lines1`),
t(`${namespace}:footer.lines2`),
t(`${namespace}:footer.lines3`),
t(`${namespace}:footer.lines4`),
];
return (
<EmailFooter>
{lines.map((line, index) => (
<Text key={index} className="text-[16px] leading-[24px] text-[#242424]" dangerouslySetInnerHTML={{ __html: line }} />
))}
</EmailFooter>
)
}

View File

@@ -2,7 +2,7 @@ import { Container, Text } from '@react-email/components';
export function EmailFooter(props: React.PropsWithChildren) {
return (
<Container>
<Container className="mt-[24px]">
<Text className="px-4 text-[12px] leading-[20px] text-gray-300">
{props.children}
</Text>

View File

@@ -0,0 +1,99 @@
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
render,
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
import { EmailContent } from '../components/content';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n';
import CommonFooter from '../components/common-footer';
interface Props {
analysisPackageName: string;
language?: string;
personName: string;
partnerLocationName: string;
}
export async function renderSynlabAnalysisPackageEmail(props: Props) {
const namespace = 'synlab-email';
const { t } = await initializeEmailI18n({
language: props.language,
namespace: [namespace, 'common'],
});
const previewText = t(`${namespace}:previewText`);
const subject = t(`${namespace}:subject`, {
analysisPackageName: props.analysisPackageName,
});
const heading = t(`${namespace}:heading`, {
analysisPackageName: props.analysisPackageName,
});
const hello = t(`${namespace}:hello`, {
personName: props.personName,
});
const lines = [
t(`${namespace}:lines1`, {
analysisPackageName: props.analysisPackageName,
partnerLocationName: props.partnerLocationName,
}),
t(`${namespace}:lines2`),
t(`${namespace}:lines3`),
t(`${namespace}:lines4`),
t(`${namespace}:lines5`),
t(`${namespace}:lines6`),
];
const html = await render(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{heading}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{hello}
</Text>
{lines.map((line, index) => (
<Text
key={index}
className="text-[16px] leading-[24px] text-[#242424]"
dangerouslySetInnerHTML={{ __html: line }}
/>
))}
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -2,3 +2,4 @@ export * from './emails/invite.email';
export * from './emails/account-delete.email';
export * from './emails/otp.email';
export * from './emails/company-offer.email';
export * from './emails/synlab.email';

View File

@@ -2,7 +2,7 @@ import { initializeServerI18n } from '@kit/i18n/server';
export function initializeEmailI18n(params: {
language: string | undefined;
namespace: string;
namespace: string | string[];
}) {
const language = params.language ?? 'en';

View File

@@ -0,0 +1,8 @@
{
"footer": {
"lines1": "MedReport",
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
"lines3": "Customer service: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
}
}

View File

@@ -0,0 +1,12 @@
{
"subject": "Your Synlab order has been placed - {{analysisPackageName}}",
"previewText": "Your Synlab order has been placed - {{analysisPackageName}}",
"heading": "Your Synlab order has been placed - {{analysisPackageName}}",
"hello": "Hello {{personName}},",
"lines1": "The order for {{analysisPackageName}} analysis package has been sent to the lab. Please go to the lab to collect the sample: SYNLAB - {{partnerLocationName}}",
"lines2": "<i>If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point - <a href=\"https://medreport.ee/et/verevotupunktid\">view locations and opening hours</a>.</i>",
"lines3": "It is recommended to collect the sample in the morning (before 12:00) and not to eat or drink (water can be drunk).",
"lines4": "At the collection point, select the order from the queue: the order from the doctor.",
"lines5": "If you have any questions, please contact us.",
"lines6": "SYNLAB customer service phone: <a href=\"tel:+37217123\">17123</a>"
}

View File

@@ -0,0 +1,8 @@
{
"footer": {
"lines1": "MedReport",
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
}
}

View File

@@ -0,0 +1,12 @@
{
"subject": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}",
"previewText": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}",
"heading": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}",
"hello": "Tere {{personName}},",
"lines1": "Saatekiri {{analysisPackageName}} analüüsi uuringuteks on saadetud laborisse digitaalselt. Palun mine proove andma: SYNLAB - {{partnerLocationName}}",
"lines2": "<i>Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - <a href=\"https://medreport.ee/et/verevotupunktid\">vaata asukohti ja lahtiolekuaegasid</a>.</i>",
"lines3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).",
"lines4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>.",
"lines5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
"lines6": "SYNLAB klienditoe telefon: <a href=\"tel:+37217123\">17123</a>"
}

View File

@@ -4,6 +4,10 @@ import { Database } from '@kit/supabase/database';
import { UserAnalysis } from '../types/accounts';
export type AccountWithParams = Database['medreport']['Tables']['accounts']['Row'] & {
account_params: Pick<Database['medreport']['Tables']['account_params']['Row'], 'weight' | 'height'> | null;
};
/**
* Class representing an API for interacting with user accounts.
* @constructor
@@ -17,11 +21,11 @@ class AccountsApi {
* @description Get the account data for the given ID.
* @param id
*/
async getAccount(id: string) {
async getAccount(id: string): Promise<AccountWithParams> {
const { data, error } = await this.client
.schema('medreport')
.from('accounts')
.select('*')
.select('*, account_params: account_params (weight, height)')
.eq('id', id)
.single();

View File

@@ -14,6 +14,7 @@ import {
} from "./cookies";
import { getRegion } from "./regions";
import { sdk } from "@lib/config";
import { retrieveOrder } from "./orders";
/**
* Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies.
@@ -161,9 +162,11 @@ export async function addToCart({
export async function updateLineItem({
lineId,
quantity,
metadata,
}: {
lineId: string;
quantity: number;
metadata?: Record<string, any>;
}) {
if (!lineId) {
throw new Error("Missing lineItem ID when updating line item");
@@ -180,7 +183,7 @@ export async function updateLineItem({
};
await sdk.store.cart
.updateLineItem(cartId, lineId, { quantity }, {}, headers)
.updateLineItem(cartId, lineId, { quantity, metadata }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
@@ -392,7 +395,7 @@ export async function setAddresses(currentState: unknown, formData: FormData) {
* @param cartId - optional - The ID of the cart to place an order for.
* @returns The cart object if the order was successful, or null if not.
*/
export async function placeOrder(cartId?: string) {
export async function placeOrder(cartId?: string, options: { revalidateCacheTags: boolean } = { revalidateCacheTags: true }) {
const id = cartId || (await getCartId());
if (!id) {
@@ -406,21 +409,26 @@ export async function placeOrder(cartId?: string) {
const cartRes = await sdk.store.cart
.complete(id, {}, headers)
.then(async (cartRes) => {
if (options?.revalidateCacheTags) {
const cartCacheTag = await getCacheTag("carts");
revalidateTag(cartCacheTag);
}
return cartRes;
})
.catch(medusaError);
if (cartRes?.type === "order") {
if (options?.revalidateCacheTags) {
const orderCacheTag = await getCacheTag("orders");
revalidateTag(orderCacheTag);
}
removeCartId();
redirect(`/home/order/${cartRes?.order.id}/confirmed`);
} else {
throw new Error("Cart is not an order");
}
return retrieveOrder(cartRes.order.id);
}
/**

View File

@@ -14,7 +14,7 @@ export const listProducts = async ({
regionId,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { collection_id?: string }
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string }
countryCode?: string
regionId?: string
}): Promise<{
@@ -134,3 +134,24 @@ export const listProductsWithSort = async ({
queryParams,
}
}
export const listProductTypes = async (): Promise<{ productTypes: HttpTypes.StoreProductType[]; count: number }> => {
const next = {
...(await getCacheOptions("productTypes")),
};
return sdk.client
.fetch<{ product_types: HttpTypes.StoreProductType[]; count: number }>(
"/store/product-types",
{
next,
cache: "force-cache",
query: {
fields: "id,value,metadata",
},
}
)
.then(({ product_types, count }) => {
return { productTypes: product_types, count };
});
};

View File

@@ -214,7 +214,15 @@ export type Database = {
recorded_at?: string
weight?: number | null
}
Relationships: []
Relationships: [
{
foreignKeyName: "account_params_account_id_fkey"
columns: ["account_id"]
isOneToOne: true
referencedRelation: "accounts"
referencedColumns: ["id"]
},
]
}
accounts: {
Row: {
@@ -308,13 +316,7 @@ export type Database = {
user_id?: string
}
Relationships: [
{
foreignKeyName: "accounts_memberships_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "accounts_memberships_account_id_fkey"
columns: ["account_id"]

1758
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,11 @@
"success": "Item removed from cart",
"loading": "Removing item from cart",
"error": "Failed to remove item from cart"
},
"analysisLocation": {
"success": "Location updated",
"loading": "Updating location",
"error": "Failed to update location"
}
},
"orderConfirmed": {
@@ -57,5 +62,10 @@
"montonioCallback": {
"title": "Montonio checkout",
"description": "Please wait while we process your payment."
},
"locations": {
"title": "Location for analysis",
"description": "If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point.",
"locationSelect": "Select location"
}
}

View File

@@ -58,6 +58,7 @@
"back": "Back",
"welcome": "Welcome",
"shoppingCart": "Shopping cart",
"shoppingCartCount": "Shopping cart ({{count}})",
"search": "Search{{end}}",
"myActions": "My actions",
"healthPackageComparison": {

View File

@@ -0,0 +1,8 @@
{
"title": "Orders",
"description": "View your orders",
"table": {
"analysisPackage": "Analysis package",
"createdAt": "Ordered at"
}
}

View File

@@ -41,6 +41,11 @@
"success": "Toode eemaldatud ostukorvist",
"loading": "Toote eemaldamine ostukorvist",
"error": "Toote eemaldamine ostukorvist ebaõnnestus"
},
"analysisLocation": {
"success": "Asukoht uuendatud",
"loading": "Asukoha uuendamine",
"error": "Asukoha uuendamine ebaõnnestus"
}
},
"orderConfirmed": {
@@ -58,5 +63,10 @@
"montonioCallback": {
"title": "Montonio makseprotsess",
"description": "Palun oodake, kuni me töötleme sinu makseprotsessi lõpuni."
},
"locations": {
"title": "Asukoht analüüside andmiseks",
"description": "Kui Teil ei ole võimalik valitud asukohta minna analüüse andma, siis võite minna Teile sobivasse verevõtupunkti.",
"locationSelect": "Vali asukoht"
}
}

View File

@@ -58,6 +58,7 @@
"back": "Back",
"welcome": "Tere tulemast",
"shoppingCart": "Ostukorv",
"shoppingCartCount": "Ostukorv ({{count}})",
"search": "Otsi{{end}}",
"myActions": "Minu toimingud",
"healthPackageComparison": {

View File

@@ -0,0 +1,8 @@
{
"title": "Tellimused",
"description": "Vaata oma tellimusi",
"table": {
"analysisPackage": "Analüüsi pakett",
"createdAt": "Tellitud"
}
}

View File

@@ -0,0 +1 @@
alter table "medreport"."account_params" add constraint "account_params_account_id_fkey" FOREIGN KEY (account_id) REFERENCES medreport.accounts(id) ON DELETE CASCADE not valid;