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

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>
</>
);