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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user