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)
|
# TO OVERRIDE THESE VARIABLES IN A SPECIFIC ENVIRONMENT, PLEASE ADD THEM TO THE SPECIFIC ENVIRONMENT FILE (e.g. .env.development, .env.production)
|
||||||
|
|
||||||
# SITE
|
# SITE
|
||||||
NEXT_PUBLIC_SITE_URL=https://localhost:3000
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_PRODUCT_NAME=MedReport
|
NEXT_PUBLIC_PRODUCT_NAME=MedReport
|
||||||
NEXT_PUBLIC_SITE_TITLE="MedReport"
|
NEXT_PUBLIC_SITE_TITLE="MedReport"
|
||||||
NEXT_PUBLIC_SITE_DESCRIPTION="MedReport."
|
NEXT_PUBLIC_SITE_DESCRIPTION="MedReport."
|
||||||
@@ -19,7 +19,7 @@ NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
|||||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
# BILLING
|
# BILLING
|
||||||
NEXT_PUBLIC_BILLING_PROVIDER=stripe
|
NEXT_PUBLIC_BILLING_PROVIDER=montonio
|
||||||
|
|
||||||
# CMS
|
# CMS
|
||||||
CMS_CLIENT=keystatic
|
CMS_CLIENT=keystatic
|
||||||
|
|||||||
@@ -58,12 +58,6 @@ export const POST = enhanceRouteHandler(
|
|||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
}) as MontonioOrderToken;
|
}) 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(
|
logger.info(
|
||||||
{
|
{
|
||||||
name: namespace,
|
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 { retrieveCart } from '~/medusa/lib/data/cart';
|
||||||
import Cart from '../../_components/cart';
|
import Cart from '../../_components/cart';
|
||||||
import { listCollections } from '@lib/data';
|
import { listProductTypes } from '@lib/data';
|
||||||
import CartTimer from '../../_components/cart/cart-timer';
|
import CartTimer from '../../_components/cart/cart-timer';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
@@ -23,15 +23,12 @@ export default async function CartPage() {
|
|||||||
return notFound();
|
return notFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
const { collections } = await listCollections({
|
const { productTypes } = await listProductTypes();
|
||||||
limit: "100",
|
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 analysisPackagesCollection = collections.find(({ handle }) => handle === 'analysis-packages');
|
|
||||||
const analysisPackages = analysisPackagesCollection && cart?.items
|
|
||||||
? cart.items.filter((item) => item.product?.collection_id === analysisPackagesCollection.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 otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
|
||||||
const item = otherItemsSorted[0];
|
const item = otherItemsSorted[0];
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { z } from 'zod';
|
|||||||
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
|
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
|
||||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||||
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||||
|
import { StoreCart } from '@medusajs/types';
|
||||||
|
import { retrieveCart } from '@lib/data';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '~/components/app-logo';
|
||||||
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||||
@@ -44,7 +46,7 @@ function SidebarLayout({ children }: React.PropsWithChildren) {
|
|||||||
</PageNavigation>
|
</PageNavigation>
|
||||||
|
|
||||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||||
<MobileNavigation workspace={workspace} />
|
<MobileNavigation workspace={workspace} cart={null} />
|
||||||
</PageMobileNavigation>
|
</PageMobileNavigation>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
@@ -66,7 +68,7 @@ function HeaderLayout({ children }: React.PropsWithChildren) {
|
|||||||
</PageNavigation>
|
</PageNavigation>
|
||||||
|
|
||||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||||
<MobileNavigation workspace={workspace} />
|
<MobileNavigation workspace={workspace} cart={cart} />
|
||||||
</PageMobileNavigation>
|
</PageMobileNavigation>
|
||||||
|
|
||||||
<SidebarProvider defaultOpen>
|
<SidebarProvider defaultOpen>
|
||||||
@@ -84,14 +86,16 @@ function HeaderLayout({ children }: React.PropsWithChildren) {
|
|||||||
|
|
||||||
function MobileNavigation({
|
function MobileNavigation({
|
||||||
workspace,
|
workspace,
|
||||||
|
cart,
|
||||||
}: {
|
}: {
|
||||||
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
|
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
|
||||||
|
cart: StoreCart | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppLogo />
|
<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 { retrieveOrder } from '~/medusa/lib/data/orders';
|
||||||
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||||
import OrderCompleted from '@/app/home/(user)/_components/order/order-completed';
|
import OrderCompleted from '@/app/home/(user)/_components/order/order-completed';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ orderId: string }>;
|
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 params = await props.params;
|
||||||
const order = await retrieveOrder(params.orderId).catch(() => null);
|
const order = await retrieveOrder(params.orderId).catch(() => null);
|
||||||
|
|
||||||
@@ -26,3 +27,5 @@ export default async function OrderConfirmedPage(props: Props) {
|
|||||||
|
|
||||||
return <OrderCompleted order={order} />;
|
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>
|
<PageBody>
|
||||||
<Dashboard />
|
<Dashboard account={account} />
|
||||||
</PageBody>
|
</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";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
|
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
|
||||||
import CartItems from "./cart-items"
|
import CartItems from "./cart-items"
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
@@ -10,11 +12,11 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
} from '@kit/ui/card';
|
} from '@kit/ui/card';
|
||||||
import DiscountCode from "./discount-code";
|
import DiscountCode from "./discount-code";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { initiatePaymentSession } from "@lib/data/cart";
|
import { initiatePaymentSession } from "@lib/data/cart";
|
||||||
import { formatCurrency } from "@/packages/shared/src/utils";
|
import { formatCurrency } from "@/packages/shared/src/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
|
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
|
||||||
|
import AnalysisLocation from "./analysis-location";
|
||||||
|
|
||||||
const IS_DISCOUNT_SHOWN = false as boolean;
|
const IS_DISCOUNT_SHOWN = false as boolean;
|
||||||
|
|
||||||
@@ -27,9 +29,10 @@ export default function Cart({
|
|||||||
analysisPackages: StoreCartLineItem[];
|
analysisPackages: StoreCartLineItem[];
|
||||||
otherItems: StoreCartLineItem[];
|
otherItems: StoreCartLineItem[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
const { i18n: { language } } = useTranslation();
|
const { i18n: { language } } = useTranslation();
|
||||||
|
|
||||||
|
const [isInitiatingSession, setIsInitiatingSession] = useState(false);
|
||||||
|
|
||||||
const items = cart?.items ?? [];
|
const items = cart?.items ?? [];
|
||||||
|
|
||||||
if (!cart || items.length === 0) {
|
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!, {
|
const response = await initiatePaymentSession(cart!, {
|
||||||
provider_id: 'pp_system_default',
|
provider_id: 'pp_montonio_montonio',
|
||||||
});
|
});
|
||||||
if (response.payment_collection) {
|
if (response.payment_collection) {
|
||||||
const url = await handleNavigateToPayment({ language });
|
const { payment_sessions } = response.payment_collection;
|
||||||
router.push(url);
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
|
<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">
|
<div className="flex flex-col bg-white gap-y-6">
|
||||||
<CartItems cart={cart} items={analysisPackages} productColumnLabelKey="cart:items.analysisPackages.productColumnLabel" />
|
<CartItems cart={cart} items={analysisPackages} productColumnLabelKey="cart:items.analysisPackages.productColumnLabel" />
|
||||||
<CartItems cart={cart} items={otherItems} productColumnLabelKey="cart:items.services.productColumnLabel" />
|
<CartItems cart={cart} items={otherItems} productColumnLabelKey="cart:items.services.productColumnLabel" />
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(cart.items) && cart.items.length > 0 && (
|
{hasCartItems && (
|
||||||
<div className="flex justify-end gap-x-4 px-6 py-4">
|
<div className="flex justify-end gap-x-4 px-6 py-4">
|
||||||
<div className="mr-[36px]">
|
<div className="mr-[36px]">
|
||||||
<p className="ml-0 font-bold text-sm">
|
<p className="ml-0 font-bold text-sm">
|
||||||
@@ -99,10 +110,26 @@ export default function Cart({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
|
||||||
<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" />
|
<Trans i18nKey="cart:checkout.goToCheckout" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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,
|
TrendingUp,
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Isikukood from 'isikukood';
|
||||||
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -27,29 +28,40 @@ import {
|
|||||||
} from '@kit/ui/card';
|
} from '@kit/ui/card';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
import { cn } from '@kit/ui/utils';
|
import { cn } from '@kit/ui/utils';
|
||||||
|
import 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',
|
title: 'dashboard:gender',
|
||||||
description: 'dashboard:male',
|
description: gender ?? 'dashboard:male',
|
||||||
icon: <User />,
|
icon: <User />,
|
||||||
iconBg: 'bg-success',
|
iconBg: 'bg-success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'dashboard:age',
|
title: 'dashboard:age',
|
||||||
description: '43',
|
description: age ? `${age}` : '-',
|
||||||
icon: <Clock9 />,
|
icon: <Clock9 />,
|
||||||
iconBg: 'bg-success',
|
iconBg: 'bg-success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'dashboard:height',
|
title: 'dashboard:height',
|
||||||
description: '183',
|
description: height ? `${height}cm` : '-',
|
||||||
icon: <RulerHorizontalIcon className="size-4" />,
|
icon: <RulerHorizontalIcon className="size-4" />,
|
||||||
iconBg: 'bg-success',
|
iconBg: 'bg-success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'dashboard:weight',
|
title: 'dashboard:weight',
|
||||||
description: '92kg',
|
description: weight ? `${weight}kg` : '-',
|
||||||
icon: <Scale />,
|
icon: <Scale />,
|
||||||
iconBg: 'bg-warning',
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid auto-rows-fr grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
<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,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function HomeMenuNavigation(props: { workspace: UserWorkspace, cart
|
|||||||
<Link href='/home/cart'>
|
<Link href='/home/cart'>
|
||||||
<Button variant="ghost" className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' >
|
<Button variant="ghost" className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' >
|
||||||
<ShoppingCart className="stroke-[1.5px]" />
|
<ShoppingCart className="stroke-[1.5px]" />
|
||||||
<Trans i18nKey="common:shoppingCart" /> ({hasCartItems ? cartItemsCount : 0})
|
<Trans i18nKey="common:shoppingCartCount" values={{ count: cartItemsCount }} />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<UserNotifications userId={user.id} />
|
<UserNotifications userId={user.id} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
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 { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@kit/ui/dropdown-menu';
|
} from '@kit/ui/dropdown-menu';
|
||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { StoreCart } from '@medusajs/types';
|
||||||
|
|
||||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||||
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.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 { HomeAccountSelector } from '../_components/home-account-selector';
|
||||||
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
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 signOut = useSignOut();
|
||||||
|
|
||||||
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
|
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 (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
@@ -69,6 +74,18 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</If>
|
</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>
|
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -83,6 +100,7 @@ function DropdownLink(
|
|||||||
props: React.PropsWithChildren<{
|
props: React.PropsWithChildren<{
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
labelOptions?: Record<string, any>;
|
||||||
Icon: React.ReactNode;
|
Icon: React.ReactNode;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
@@ -95,7 +113,7 @@ function DropdownLink(
|
|||||||
{props.Icon}
|
{props.Icon}
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey={props.label} defaults={props.label} />
|
<Trans i18nKey={props.label} defaults={props.label} values={props.labelOptions} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function OrderItem({ item, currencyCode }: {
|
|||||||
className="txt-medium-plus text-ui-fg-base"
|
className="txt-medium-plus text-ui-fg-base"
|
||||||
data-testid="product-name"
|
data-testid="product-name"
|
||||||
>
|
>
|
||||||
{item.product_title}
|
{item.product_title}{` (${item.metadata?.partner_location_name ?? "-"})`}
|
||||||
</span>
|
</span>
|
||||||
<LineItemOptions variant={item.variant} data-testid="product-variant" />
|
<LineItemOptions variant={item.variant} data-testid="product-variant" />
|
||||||
</TableCell>
|
</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 { cache } from 'react';
|
||||||
|
|
||||||
import { listCollections, listProducts, listRegions } from "@lib/data";
|
import { listProductTypes, listProducts, listRegions } from "@lib/data";
|
||||||
|
|
||||||
async function countryCodesLoader() {
|
async function countryCodesLoader() {
|
||||||
const countryCodes = await listRegions().then((regions) =>
|
const countryCodes = await listRegions().then((regions) =>
|
||||||
@@ -10,26 +10,24 @@ async function countryCodesLoader() {
|
|||||||
}
|
}
|
||||||
export const loadCountryCodes = cache(countryCodesLoader);
|
export const loadCountryCodes = cache(countryCodesLoader);
|
||||||
|
|
||||||
async function collectionsLoader() {
|
async function productTypesLoader() {
|
||||||
const { collections } = await listCollections({
|
const { productTypes } = await listProductTypes();
|
||||||
fields: 'id, handle',
|
return productTypes ?? [];
|
||||||
});
|
|
||||||
return collections ?? [];
|
|
||||||
}
|
}
|
||||||
export const loadCollections = cache(collectionsLoader);
|
export const loadProductTypes = cache(productTypesLoader);
|
||||||
|
|
||||||
async function analysisPackagesLoader() {
|
async function analysisPackagesLoader() {
|
||||||
const [countryCodes, collections] = await Promise.all([loadCountryCodes(), loadCollections()]);
|
const [countryCodes, productTypes] = await Promise.all([loadCountryCodes(), loadProductTypes()]);
|
||||||
const countryCode = countryCodes[0]!;
|
const countryCode = countryCodes[0]!;
|
||||||
|
|
||||||
const collection = collections.find(({ handle }) => handle === 'analysis-packages');
|
const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
|
||||||
if (!collection) {
|
if (!productType) {
|
||||||
return { analysisPackages: [], countryCode };
|
return { analysisPackages: [], countryCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { response } = await listProducts({
|
const { response } = await listProducts({
|
||||||
countryCode,
|
countryCode,
|
||||||
queryParams: { limit: 100, collection_id: collection?.id },
|
queryParams: { limit: 100, "type_id[0]": productType.id },
|
||||||
});
|
});
|
||||||
return { analysisPackages: response.products, countryCode };
|
return { analysisPackages: response.products, countryCode };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,10 @@ const pathsConfig = PathsSchema.parse({
|
|||||||
selectPackage: '/select-package',
|
selectPackage: '/select-package',
|
||||||
booking: '/home/booking',
|
booking: '/home/booking',
|
||||||
orderAnalysisPackage: '/home/order-analysis-package',
|
orderAnalysisPackage: '/home/order-analysis-package',
|
||||||
// these routes are added as placeholders and can be changed when the pages are added
|
myOrders: '/home/order',
|
||||||
myOrders: '/my-orders',
|
|
||||||
analysisResults: '/home/analysis-results',
|
analysisResults: '/home/analysis-results',
|
||||||
orderAnalysis: '/order-analysis',
|
orderAnalysis: '/home/order-analysis',
|
||||||
orderHealthAnalysis: '/order-health-analysis',
|
orderHealthAnalysis: '/home/order-health-analysis',
|
||||||
},
|
},
|
||||||
} satisfies z.infer<typeof PathsSchema>);
|
} satisfies z.infer<typeof PathsSchema>);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const defaultI18nNamespaces = [
|
|||||||
'booking',
|
'booking',
|
||||||
'order-analysis-package',
|
'order-analysis-package',
|
||||||
'cart',
|
'cart',
|
||||||
|
'orders',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
|
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
|
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
|
||||||
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
|
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
|
||||||
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
|
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
|
||||||
import { headers } from 'next/headers';
|
|
||||||
import { requireUserInServerComponent } from '../server/require-user-in-server-component';
|
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({
|
export async function handleAddToCart({
|
||||||
selectedVariant,
|
selectedVariant,
|
||||||
countryCode,
|
countryCode,
|
||||||
@@ -46,7 +67,7 @@ export async function handleAddToCart({
|
|||||||
return cart;
|
return cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleNavigateToPayment({ language }: { language: string }) {
|
export async function handleNavigateToPayment({ language, paymentSessionId }: { language: string, paymentSessionId: string }) {
|
||||||
const supabase = getSupabaseServerClient();
|
const supabase = getSupabaseServerClient();
|
||||||
const user = await requireUserInServerComponent();
|
const user = await requireUserInServerComponent();
|
||||||
const account = await loadCurrentUserAccount()
|
const account = await loadCurrentUserAccount()
|
||||||
@@ -59,21 +80,14 @@ export async function handleNavigateToPayment({ language }: { language: string }
|
|||||||
throw new Error("No cart found");
|
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({
|
const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({
|
||||||
notificationUrl: `${publicUrl}/api/billing/webhook`,
|
notificationUrl: `${env.medusaBackendUrl}/api/billing/webhook`,
|
||||||
returnUrl: `${publicUrl}/home/cart/montonio-callback`,
|
returnUrl: `${env.siteUrl}/home/cart/montonio-callback`,
|
||||||
amount: cart.total,
|
amount: cart.total,
|
||||||
currency: cart.currency_code.toUpperCase(),
|
currency: cart.currency_code.toUpperCase(),
|
||||||
description: `Order from Medreport`,
|
description: `Order from Medreport`,
|
||||||
locale: language,
|
locale: language,
|
||||||
merchantReference: `${account.id}:${cart.id}:${Date.now()}`,
|
merchantReference: `${account.id}:${paymentSessionId}:${cart.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
@@ -104,12 +118,6 @@ export async function handleLineItemTimeout({
|
|||||||
throw new Error('Account not found');
|
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);
|
await deleteLineItem(lineItem.id);
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CsrfError, createCsrfProtect } from '@edge-csrf/nextjs';
|
|||||||
import { isSuperAdmin } from '@kit/admin';
|
import { isSuperAdmin } from '@kit/admin';
|
||||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||||
import { createMiddlewareClient } from '@kit/supabase/middleware-client';
|
import { createMiddlewareClient } from '@kit/supabase/middleware-client';
|
||||||
|
import { middleware as medusaMiddleware } from "~/medusa/middleware";
|
||||||
|
|
||||||
import appConfig from '~/config/app.config';
|
import appConfig from '~/config/app.config';
|
||||||
import pathsConfig from '~/config/paths.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);
|
csrfResponse.headers.set('x-action-path', request.nextUrl.pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await medusaMiddleware(request as any);
|
||||||
|
|
||||||
// if no pattern handler returned a response,
|
// if no pattern handler returned a response,
|
||||||
// return the session response
|
// return the session response
|
||||||
return csrfResponse;
|
return csrfResponse;
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fast-xml-parser": "^5.2.5",
|
"fast-xml-parser": "^5.2.5",
|
||||||
|
"isikukood": "3.1.7",
|
||||||
"jsonwebtoken": "9.0.2",
|
"jsonwebtoken": "9.0.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class MontonioOrderHandlerService {
|
|||||||
locale: string;
|
locale: string;
|
||||||
merchantReference: string;
|
merchantReference: string;
|
||||||
}) {
|
}) {
|
||||||
const token = jwt.sign({
|
const params = {
|
||||||
accessKey,
|
accessKey,
|
||||||
description,
|
description,
|
||||||
currency,
|
currency,
|
||||||
@@ -38,12 +38,13 @@ export class MontonioOrderHandlerService {
|
|||||||
locale,
|
locale,
|
||||||
// 15 minutes
|
// 15 minutes
|
||||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||||
notificationUrl,
|
notificationUrl: notificationUrl.replace("localhost", "webhook.site"),
|
||||||
returnUrl,
|
returnUrl: returnUrl.replace("localhost", "webhook.site"),
|
||||||
askAdditionalInfo: false,
|
askAdditionalInfo: false,
|
||||||
merchantReference,
|
merchantReference,
|
||||||
type: "one_time",
|
type: "one_time",
|
||||||
}, secretKey, {
|
};
|
||||||
|
const token = jwt.sign(params, secretKey, {
|
||||||
algorithm: "HS256",
|
algorithm: "HS256",
|
||||||
expiresIn: "10m",
|
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) {
|
export function EmailFooter(props: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container className="mt-[24px]">
|
||||||
<Text className="px-4 text-[12px] leading-[20px] text-gray-300">
|
<Text className="px-4 text-[12px] leading-[20px] text-gray-300">
|
||||||
{props.children}
|
{props.children}
|
||||||
</Text>
|
</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/account-delete.email';
|
||||||
export * from './emails/otp.email';
|
export * from './emails/otp.email';
|
||||||
export * from './emails/company-offer.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: {
|
export function initializeEmailI18n(params: {
|
||||||
language: string | undefined;
|
language: string | undefined;
|
||||||
namespace: string;
|
namespace: string | string[];
|
||||||
}) {
|
}) {
|
||||||
const language = params.language ?? 'en';
|
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';
|
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.
|
* Class representing an API for interacting with user accounts.
|
||||||
* @constructor
|
* @constructor
|
||||||
@@ -17,11 +21,11 @@ class AccountsApi {
|
|||||||
* @description Get the account data for the given ID.
|
* @description Get the account data for the given ID.
|
||||||
* @param id
|
* @param id
|
||||||
*/
|
*/
|
||||||
async getAccount(id: string) {
|
async getAccount(id: string): Promise<AccountWithParams> {
|
||||||
const { data, error } = await this.client
|
const { data, error } = await this.client
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select('*')
|
.select('*, account_params: account_params (weight, height)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "./cookies";
|
} from "./cookies";
|
||||||
import { getRegion } from "./regions";
|
import { getRegion } from "./regions";
|
||||||
import { sdk } from "@lib/config";
|
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.
|
* 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({
|
export async function updateLineItem({
|
||||||
lineId,
|
lineId,
|
||||||
quantity,
|
quantity,
|
||||||
|
metadata,
|
||||||
}: {
|
}: {
|
||||||
lineId: string;
|
lineId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
}) {
|
}) {
|
||||||
if (!lineId) {
|
if (!lineId) {
|
||||||
throw new Error("Missing lineItem ID when updating line item");
|
throw new Error("Missing lineItem ID when updating line item");
|
||||||
@@ -180,7 +183,7 @@ export async function updateLineItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
await sdk.store.cart
|
await sdk.store.cart
|
||||||
.updateLineItem(cartId, lineId, { quantity }, {}, headers)
|
.updateLineItem(cartId, lineId, { quantity, metadata }, {}, headers)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const cartCacheTag = await getCacheTag("carts");
|
const cartCacheTag = await getCacheTag("carts");
|
||||||
revalidateTag(cartCacheTag);
|
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.
|
* @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.
|
* @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());
|
const id = cartId || (await getCartId());
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -406,21 +409,26 @@ export async function placeOrder(cartId?: string) {
|
|||||||
const cartRes = await sdk.store.cart
|
const cartRes = await sdk.store.cart
|
||||||
.complete(id, {}, headers)
|
.complete(id, {}, headers)
|
||||||
.then(async (cartRes) => {
|
.then(async (cartRes) => {
|
||||||
|
if (options?.revalidateCacheTags) {
|
||||||
const cartCacheTag = await getCacheTag("carts");
|
const cartCacheTag = await getCacheTag("carts");
|
||||||
revalidateTag(cartCacheTag);
|
revalidateTag(cartCacheTag);
|
||||||
|
}
|
||||||
return cartRes;
|
return cartRes;
|
||||||
})
|
})
|
||||||
.catch(medusaError);
|
.catch(medusaError);
|
||||||
|
|
||||||
if (cartRes?.type === "order") {
|
if (cartRes?.type === "order") {
|
||||||
|
if (options?.revalidateCacheTags) {
|
||||||
const orderCacheTag = await getCacheTag("orders");
|
const orderCacheTag = await getCacheTag("orders");
|
||||||
revalidateTag(orderCacheTag);
|
revalidateTag(orderCacheTag);
|
||||||
|
}
|
||||||
|
|
||||||
removeCartId();
|
removeCartId();
|
||||||
redirect(`/home/order/${cartRes?.order.id}/confirmed`);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Cart is not an order");
|
throw new Error("Cart is not an order");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return retrieveOrder(cartRes.order.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const listProducts = async ({
|
|||||||
regionId,
|
regionId,
|
||||||
}: {
|
}: {
|
||||||
pageParam?: number
|
pageParam?: number
|
||||||
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { collection_id?: string }
|
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string }
|
||||||
countryCode?: string
|
countryCode?: string
|
||||||
regionId?: string
|
regionId?: string
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -134,3 +134,24 @@ export const listProductsWithSort = async ({
|
|||||||
queryParams,
|
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
|
recorded_at?: string
|
||||||
weight?: number | null
|
weight?: number | null
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "account_params_account_id_fkey"
|
||||||
|
columns: ["account_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "accounts"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
accounts: {
|
accounts: {
|
||||||
Row: {
|
Row: {
|
||||||
@@ -308,13 +316,7 @@ export type Database = {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
{
|
|
||||||
foreignKeyName: "accounts_memberships_account_id_fkey"
|
|
||||||
columns: ["account_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "accounts"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
foreignKeyName: "accounts_memberships_account_id_fkey"
|
foreignKeyName: "accounts_memberships_account_id_fkey"
|
||||||
columns: ["account_id"]
|
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",
|
"success": "Item removed from cart",
|
||||||
"loading": "Removing item from cart",
|
"loading": "Removing item from cart",
|
||||||
"error": "Failed to remove item from cart"
|
"error": "Failed to remove item from cart"
|
||||||
|
},
|
||||||
|
"analysisLocation": {
|
||||||
|
"success": "Location updated",
|
||||||
|
"loading": "Updating location",
|
||||||
|
"error": "Failed to update location"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"orderConfirmed": {
|
"orderConfirmed": {
|
||||||
@@ -57,5 +62,10 @@
|
|||||||
"montonioCallback": {
|
"montonioCallback": {
|
||||||
"title": "Montonio checkout",
|
"title": "Montonio checkout",
|
||||||
"description": "Please wait while we process your payment."
|
"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",
|
"back": "Back",
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
"shoppingCart": "Shopping cart",
|
"shoppingCart": "Shopping cart",
|
||||||
|
"shoppingCartCount": "Shopping cart ({{count}})",
|
||||||
"search": "Search{{end}}",
|
"search": "Search{{end}}",
|
||||||
"myActions": "My actions",
|
"myActions": "My actions",
|
||||||
"healthPackageComparison": {
|
"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",
|
"success": "Toode eemaldatud ostukorvist",
|
||||||
"loading": "Toote eemaldamine ostukorvist",
|
"loading": "Toote eemaldamine ostukorvist",
|
||||||
"error": "Toote eemaldamine ostukorvist ebaõnnestus"
|
"error": "Toote eemaldamine ostukorvist ebaõnnestus"
|
||||||
|
},
|
||||||
|
"analysisLocation": {
|
||||||
|
"success": "Asukoht uuendatud",
|
||||||
|
"loading": "Asukoha uuendamine",
|
||||||
|
"error": "Asukoha uuendamine ebaõnnestus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"orderConfirmed": {
|
"orderConfirmed": {
|
||||||
@@ -58,5 +63,10 @@
|
|||||||
"montonioCallback": {
|
"montonioCallback": {
|
||||||
"title": "Montonio makseprotsess",
|
"title": "Montonio makseprotsess",
|
||||||
"description": "Palun oodake, kuni me töötleme sinu makseprotsessi lõpuni."
|
"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",
|
"back": "Back",
|
||||||
"welcome": "Tere tulemast",
|
"welcome": "Tere tulemast",
|
||||||
"shoppingCart": "Ostukorv",
|
"shoppingCart": "Ostukorv",
|
||||||
|
"shoppingCartCount": "Ostukorv ({{count}})",
|
||||||
"search": "Otsi{{end}}",
|
"search": "Otsi{{end}}",
|
||||||
"myActions": "Minu toimingud",
|
"myActions": "Minu toimingud",
|
||||||
"healthPackageComparison": {
|
"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