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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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