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:
4
.env
4
.env
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
28
app/home/(user)/(dashboard)/order-analysis/page.tsx
Normal file
28
app/home/(user)/(dashboard)/order-analysis/page.tsx
Normal 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);
|
||||
28
app/home/(user)/(dashboard)/order-health-analysis/page.tsx
Normal file
28
app/home/(user)/(dashboard)/order-health-analysis/page.tsx
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
49
app/home/(user)/(dashboard)/order/page.tsx
Normal file
49
app/home/(user)/(dashboard)/order/page.tsx
Normal 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);
|
||||
@@ -37,7 +37,7 @@ async function UserHomePage() {
|
||||
}
|
||||
/>
|
||||
<PageBody>
|
||||
<Dashboard />
|
||||
<Dashboard account={account} />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
|
||||
103
app/home/(user)/_components/cart/analysis-location.tsx
Normal file
103
app/home/(user)/_components/cart/analysis-location.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
22
app/home/(user)/_components/cart/types.ts
Normal file
22
app/home/(user)/_components/cart/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
44
app/home/(user)/_components/orders/orders-item.tsx
Normal file
44
app/home/(user)/_components/orders/orders-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
app/home/(user)/_components/orders/orders-table.tsx
Normal file
46
app/home/(user)/_components/orders/orders-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
app/home/(user)/_components/orders/types.ts
Normal file
7
app/home/(user)/_components/orders/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { StoreOrderLineItem } from "@medusajs/types";
|
||||
|
||||
export interface IAnalysisPackageOrder {
|
||||
item: StoreOrderLineItem;
|
||||
orderId: string;
|
||||
orderStatus: string;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export const defaultI18nNamespaces = [
|
||||
'booking',
|
||||
'order-analysis-package',
|
||||
'cart',
|
||||
'orders',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
22
packages/email-templates/src/components/common-footer.tsx
Normal file
22
packages/email-templates/src/components/common-footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
99
packages/email-templates/src/emails/synlab.email.tsx
Normal file
99
packages/email-templates/src/emails/synlab.email.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
8
packages/email-templates/src/locales/en/common.json
Normal file
8
packages/email-templates/src/locales/en/common.json
Normal 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>"
|
||||
}
|
||||
}
|
||||
12
packages/email-templates/src/locales/en/synlab-email.json
Normal file
12
packages/email-templates/src/locales/en/synlab-email.json
Normal 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>"
|
||||
}
|
||||
8
packages/email-templates/src/locales/et/common.json
Normal file
8
packages/email-templates/src/locales/et/common.json
Normal 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>"
|
||||
}
|
||||
}
|
||||
12
packages/email-templates/src/locales/et/synlab-email.json
Normal file
12
packages/email-templates/src/locales/et/synlab-email.json
Normal 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>"
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
const cartCacheTag = await getCacheTag("carts");
|
||||
revalidateTag(cartCacheTag);
|
||||
if (options?.revalidateCacheTags) {
|
||||
const cartCacheTag = await getCacheTag("carts");
|
||||
revalidateTag(cartCacheTag);
|
||||
}
|
||||
return cartRes;
|
||||
})
|
||||
.catch(medusaError);
|
||||
|
||||
if (cartRes?.type === "order") {
|
||||
const orderCacheTag = await getCacheTag("orders");
|
||||
revalidateTag(orderCacheTag);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
1758
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
"back": "Back",
|
||||
"welcome": "Welcome",
|
||||
"shoppingCart": "Shopping cart",
|
||||
"shoppingCartCount": "Shopping cart ({{count}})",
|
||||
"search": "Search{{end}}",
|
||||
"myActions": "My actions",
|
||||
"healthPackageComparison": {
|
||||
|
||||
8
public/locales/en/orders.json
Normal file
8
public/locales/en/orders.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "Orders",
|
||||
"description": "View your orders",
|
||||
"table": {
|
||||
"analysisPackage": "Analysis package",
|
||||
"createdAt": "Ordered at"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
"back": "Back",
|
||||
"welcome": "Tere tulemast",
|
||||
"shoppingCart": "Ostukorv",
|
||||
"shoppingCartCount": "Ostukorv ({{count}})",
|
||||
"search": "Otsi{{end}}",
|
||||
"myActions": "Minu toimingud",
|
||||
"healthPackageComparison": {
|
||||
|
||||
8
public/locales/et/orders.json
Normal file
8
public/locales/et/orders.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "Tellimused",
|
||||
"description": "Vaata oma tellimusi",
|
||||
"table": {
|
||||
"analysisPackage": "Analüüsi pakett",
|
||||
"createdAt": "Tellitud"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user