feat(MED-100): update cart checkout flow and views
This commit is contained in:
97
app/api/montonio/verify-token/route.ts
Normal file
97
app/api/montonio/verify-token/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BodySchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = enhanceRouteHandler(
|
||||||
|
async ({ request }) => {
|
||||||
|
const logger = await getLogger();
|
||||||
|
const body = await request.json();
|
||||||
|
const namespace = 'montonio.verify-token';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { token } = BodySchema.parse(body);
|
||||||
|
|
||||||
|
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
|
||||||
|
|
||||||
|
if (!secretKey) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
name: namespace,
|
||||||
|
},
|
||||||
|
`Missing MONTONIO_SECRET_KEY`,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error('Server misconfiguration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, secretKey, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
}) as MontonioOrderToken;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
name: namespace,
|
||||||
|
status: decoded.paymentStatus,
|
||||||
|
orderId: decoded.uuid,
|
||||||
|
},
|
||||||
|
`Successfully verified Montonio token.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: decoded.paymentStatus,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
name: namespace,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
`Failed to verify Montonio token`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : 'Invalid token';
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
5
app/home/(user)/(dashboard)/cart/loading.tsx
Normal file
5
app/home/(user)/(dashboard)/cart/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SkeletonCartPage from '~/medusa/modules/skeletons/templates/skeleton-cart-page';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SkeletonCartPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
app/home/(user)/(dashboard)/cart/page.tsx
Normal file
47
app/home/(user)/(dashboard)/cart/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||||
|
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
|
||||||
|
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { retrieveCart } from '~/medusa/lib/data/cart';
|
||||||
|
import Cart from '../../_components/cart';
|
||||||
|
import { listCollections } from '@lib/data';
|
||||||
|
import CartTimer from '../../_components/cart/cart-timer';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('cart:title'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CartPage() {
|
||||||
|
const cart = await retrieveCart().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
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 otherItems = cart?.items?.filter((item) => item.product?.collection_id !== analysisPackagesCollection?.id) ?? [];
|
||||||
|
|
||||||
|
const otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
|
||||||
|
const item = otherItemsSorted[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageBody>
|
||||||
|
<PageHeader title={<Trans i18nKey="cart:title" />}>
|
||||||
|
{item && item.updated_at && <CartTimer cartItem={item} />}
|
||||||
|
</PageHeader>
|
||||||
|
<Cart cart={cart} analysisPackages={analysisPackages} otherItems={otherItems} />
|
||||||
|
</PageBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { HomeMenuNavigation } from '../_components/home-menu-navigation';
|
|||||||
import { HomeMobileNavigation } from '../_components/home-mobile-navigation';
|
import { HomeMobileNavigation } from '../_components/home-mobile-navigation';
|
||||||
import { HomeSidebar } from '../_components/home-sidebar';
|
import { HomeSidebar } from '../_components/home-sidebar';
|
||||||
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
|
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
|
||||||
|
import { retrieveCart } from '@lib/data';
|
||||||
|
|
||||||
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
||||||
const state = use(getLayoutState());
|
const state = use(getLayoutState());
|
||||||
@@ -55,12 +56,13 @@ function SidebarLayout({ children }: React.PropsWithChildren) {
|
|||||||
|
|
||||||
function HeaderLayout({ children }: React.PropsWithChildren) {
|
function HeaderLayout({ children }: React.PropsWithChildren) {
|
||||||
const workspace = use(loadUserWorkspace());
|
const workspace = use(loadUserWorkspace());
|
||||||
|
const cart = use(retrieveCart());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserWorkspaceContextProvider value={workspace}>
|
<UserWorkspaceContextProvider value={workspace}>
|
||||||
<Page style={'header'}>
|
<Page style={'header'}>
|
||||||
<PageNavigation>
|
<PageNavigation>
|
||||||
<HomeMenuNavigation workspace={workspace} />
|
<HomeMenuNavigation workspace={workspace} cart={cart} />
|
||||||
</PageNavigation>
|
</PageNavigation>
|
||||||
|
|
||||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PageBody } from '@kit/ui/page';
|
|||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import ComparePackagesModal from '../../_components/compare-packages-modal';
|
import ComparePackagesModal from '../../_components/compare-packages-modal';
|
||||||
|
import { loadAnalysisPackages } from '../../_lib/server/load-analysis-packages';
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
const i18n = await createI18nServerInstance();
|
const i18n = await createI18nServerInstance();
|
||||||
@@ -19,6 +20,8 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function OrderAnalysisPackagePage() {
|
async function OrderAnalysisPackagePage() {
|
||||||
|
const { analysisPackages, countryCode } = await loadAnalysisPackages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<div className="space-y-3 text-center">
|
<div className="space-y-3 text-center">
|
||||||
@@ -26,6 +29,7 @@ async function OrderAnalysisPackagePage() {
|
|||||||
<Trans i18nKey={'marketing:selectPackage'} />
|
<Trans i18nKey={'marketing:selectPackage'} />
|
||||||
</h3>
|
</h3>
|
||||||
<ComparePackagesModal
|
<ComparePackagesModal
|
||||||
|
analysisPackages={analysisPackages}
|
||||||
triggerElement={
|
triggerElement={
|
||||||
<Button variant="secondary" className="gap-2">
|
<Button variant="secondary" className="gap-2">
|
||||||
<Trans i18nKey={'marketing:comparePackages'} />
|
<Trans i18nKey={'marketing:comparePackages'} />
|
||||||
@@ -34,7 +38,7 @@ async function OrderAnalysisPackagePage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SelectAnalysisPackages />
|
<SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
|
||||||
</PageBody>
|
</PageBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ orderId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('cart:orderConfirmed.title'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrderConfirmedPage(props: Props) {
|
||||||
|
const params = await props.params;
|
||||||
|
const order = await retrieveOrder(params.orderId).catch(() => null);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OrderCompleted order={order} />;
|
||||||
|
}
|
||||||
57
app/home/(user)/_components/cart/cart-item.tsx
Normal file
57
app/home/(user)/_components/cart/cart-item.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import DeleteButton from "@modules/common/components/delete-button"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import {
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
} from '@kit/ui/table';
|
||||||
|
import { formatCurrency } from "@/packages/shared/src/utils"
|
||||||
|
import { Trash } from "lucide-react"
|
||||||
|
|
||||||
|
export default function CartItem({ item, currencyCode }: {
|
||||||
|
item: HttpTypes.StoreCartLineItem
|
||||||
|
currencyCode: string
|
||||||
|
}) {
|
||||||
|
const { i18n: { language } } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow className="w-full" data-testid="product-row">
|
||||||
|
<TableCell className="text-left w-[100%] px-6">
|
||||||
|
<p
|
||||||
|
className="txt-medium-plus text-ui-fg-base"
|
||||||
|
data-testid="product-title"
|
||||||
|
>
|
||||||
|
{item.product_title}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="px-6">
|
||||||
|
{item.quantity}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="min-w-[80px] px-6">
|
||||||
|
{formatCurrency({
|
||||||
|
value: item.unit_price,
|
||||||
|
currencyCode,
|
||||||
|
locale: language,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="min-w-[80px] px-6">
|
||||||
|
{formatCurrency({
|
||||||
|
value: item.total,
|
||||||
|
currencyCode,
|
||||||
|
locale: language,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right px-6">
|
||||||
|
<span className="flex gap-x-1 justify-end w-[60px]">
|
||||||
|
<DeleteButton id={item.id} data-testid="product-delete-button" Icon={<Trash />} />
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
app/home/(user)/_components/cart/cart-items.tsx
Normal file
54
app/home/(user)/_components/cart/cart-items.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import CartItem from "./cart-item";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
} from '@kit/ui/table';
|
||||||
|
|
||||||
|
export default function CartItems({ cart, items, productColumnLabelKey }: {
|
||||||
|
cart: StoreCart;
|
||||||
|
items: StoreCartLineItem[];
|
||||||
|
productColumnLabelKey: string;
|
||||||
|
}) {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table className="rounded-lg border border-separate">
|
||||||
|
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
||||||
|
<TableRow className="">
|
||||||
|
<TableHead className="px-6">
|
||||||
|
<Trans i18nKey={productColumnLabelKey} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-6">
|
||||||
|
<Trans i18nKey="cart:table.quantity" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-6 min-w-[100px]">
|
||||||
|
<Trans i18nKey="cart:table.price" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-6 min-w-[100px]">
|
||||||
|
<Trans i18nKey="cart:table.total" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-6">
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items
|
||||||
|
.sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1)
|
||||||
|
.map((item) => (
|
||||||
|
<CartItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
currencyCode={cart.currency_code}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
app/home/(user)/_components/cart/cart-timer.tsx
Normal file
91
app/home/(user)/_components/cart/cart-timer.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle
|
||||||
|
} from "@kit/ui/alert-dialog";
|
||||||
|
|
||||||
|
import { Timer } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { StoreCartLineItem } from '@medusajs/types';
|
||||||
|
import { handleLineItemTimeout } from '@/lib/services/medusaCart.service';
|
||||||
|
|
||||||
|
const TIMEOUT_MINUTES = 15;
|
||||||
|
|
||||||
|
export default function CartTimer({ cartItem }: { cartItem: StoreCartLineItem }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
||||||
|
const [isDialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const updatedAt = cartItem.updated_at!;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const date = new Date(updatedAt);
|
||||||
|
date.setMinutes(date.getMinutes() + TIMEOUT_MINUTES);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = date.getTime() - now.getTime();
|
||||||
|
setTimeLeft(diff);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [updatedAt]);
|
||||||
|
|
||||||
|
const minutes = timeLeft ? Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)) : 0;
|
||||||
|
const seconds = timeLeft ? Math.floor((timeLeft % (1000 * 60)) / 1000) : 0;
|
||||||
|
|
||||||
|
const isTimeLeftPositive = timeLeft === null || timeLeft > 0;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTimeLeftPositive) {
|
||||||
|
void handleLineItemTimeout({
|
||||||
|
lineItem: cartItem,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
}, [isTimeLeftPositive, cartItem.id]);
|
||||||
|
|
||||||
|
if (timeLeft === null) {
|
||||||
|
return <div className='min-h-[40px]' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Button variant="outline" className="flex items-center gap-x-2 bg-accent hover:bg-accent px-4 cursor-default">
|
||||||
|
<Timer />
|
||||||
|
<span className="text-sm">
|
||||||
|
{t('cart:checkout.timeLeft', {
|
||||||
|
timeLeft: `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t('cart:checkout.timeoutTitle')}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('cart:checkout.timeoutDescription', { productTitle: cartItem.product?.title })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction onClick={() => setDialogOpen(false)}>
|
||||||
|
{t('cart:checkout.timeoutAction')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
app/home/(user)/_components/cart/discount-code.tsx
Normal file
166
app/home/(user)/_components/cart/discount-code.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge, Heading, Text } from "@medusajs/ui"
|
||||||
|
import React, { useActionState } from "react";
|
||||||
|
|
||||||
|
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
|
||||||
|
import { convertToLocale } from "@lib/util/money"
|
||||||
|
import { StoreCart, StorePromotion } from "@medusajs/types"
|
||||||
|
import Trash from "@modules/common/icons/trash"
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Form, FormControl, FormField, FormItem } from "@kit/ui/form";
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { Input } from "@kit/ui/input";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const DiscountCodeSchema = z.object({
|
||||||
|
code: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function DiscountCode({ cart }: {
|
||||||
|
cart: StoreCart & {
|
||||||
|
promotions: StorePromotion[]
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('cart');
|
||||||
|
|
||||||
|
const { promotions = [] } = cart;
|
||||||
|
|
||||||
|
const removePromotionCode = async (code: string) => {
|
||||||
|
const validPromotions = promotions.filter(
|
||||||
|
(promotion) => promotion.code !== code
|
||||||
|
)
|
||||||
|
|
||||||
|
await applyPromotions(
|
||||||
|
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPromotionCode = async (code: string) => {
|
||||||
|
const codes = promotions
|
||||||
|
.filter((p) => p.code === undefined)
|
||||||
|
.map((p) => p.code!)
|
||||||
|
codes.push(code.toString())
|
||||||
|
|
||||||
|
await applyPromotions(codes)
|
||||||
|
|
||||||
|
form.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const [message, formAction] = useActionState(submitPromotionForm, null)
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof DiscountCodeSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
code: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(DiscountCodeSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white flex flex-col txt-medium">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
|
||||||
|
className="w-full mb-2 flex gap-x-2"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name={'code'}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input required type="text" {...field} placeholder={t('cart:discountCode.placeholder')} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<Trans i18nKey={'cart:discountCode.apply'} />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Trans i18nKey={'cart:discountCode.subtitle'} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{promotions.length > 0 && (
|
||||||
|
<div className="w-full flex items-center">
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<Heading className="txt-medium mb-2">
|
||||||
|
Promotion(s) applied:
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{promotions.map((promotion) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={promotion.id}
|
||||||
|
className="flex items-center justify-between w-full max-w-full mb-2"
|
||||||
|
data-testid="discount-row"
|
||||||
|
>
|
||||||
|
<Text className="flex gap-x-1 items-baseline txt-small-plus w-4/5 pr-1">
|
||||||
|
<span className="truncate" data-testid="discount-code">
|
||||||
|
<Badge
|
||||||
|
color={promotion.is_automatic ? "green" : "grey"}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{promotion.code}
|
||||||
|
</Badge>{" "}
|
||||||
|
(
|
||||||
|
{promotion.application_method?.value !== undefined &&
|
||||||
|
promotion.application_method.currency_code !==
|
||||||
|
undefined && (
|
||||||
|
<>
|
||||||
|
{promotion.application_method.type ===
|
||||||
|
"percentage"
|
||||||
|
? `${promotion.application_method.value}%`
|
||||||
|
: convertToLocale({
|
||||||
|
amount: promotion.application_method.value,
|
||||||
|
currency_code:
|
||||||
|
promotion.application_method
|
||||||
|
.currency_code,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
{/* {promotion.is_automatic && (
|
||||||
|
<Tooltip content="This promotion is automatically applied">
|
||||||
|
<InformationCircleSolid className="inline text-zinc-400" />
|
||||||
|
</Tooltip>
|
||||||
|
)} */}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
{!promotion.is_automatic && (
|
||||||
|
<button
|
||||||
|
className="flex items-center"
|
||||||
|
onClick={() => {
|
||||||
|
if (!promotion.code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removePromotionCode(promotion.code)
|
||||||
|
}}
|
||||||
|
data-testid="remove-discount-button"
|
||||||
|
>
|
||||||
|
<Trash size={14} />
|
||||||
|
<span className="sr-only">
|
||||||
|
Remove discount code from order
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
app/home/(user)/_components/cart/index.tsx
Normal file
111
app/home/(user)/_components/cart/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
|
||||||
|
import CartItems from "./cart-items"
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
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";
|
||||||
|
|
||||||
|
const IS_DISCOUNT_SHOWN = false as boolean;
|
||||||
|
|
||||||
|
export default function Cart({
|
||||||
|
cart,
|
||||||
|
analysisPackages,
|
||||||
|
otherItems,
|
||||||
|
}: {
|
||||||
|
cart: StoreCart | null
|
||||||
|
analysisPackages: StoreCartLineItem[];
|
||||||
|
otherItems: StoreCartLineItem[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { i18n: { language } } = useTranslation();
|
||||||
|
|
||||||
|
const items = cart?.items ?? [];
|
||||||
|
|
||||||
|
if (!cart || items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="content-container py-5 lg:px-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col justify-center items-center" data-testid="empty-cart-message">
|
||||||
|
<h4 className="text-center">
|
||||||
|
<Trans i18nKey="cart:emptyCartMessage" />
|
||||||
|
</h4>
|
||||||
|
<p className="text-center">
|
||||||
|
<Trans i18nKey="cart:emptyCartMessageDescription" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePayment() {
|
||||||
|
const response = await initiatePaymentSession(cart!, {
|
||||||
|
provider_id: 'pp_system_default',
|
||||||
|
});
|
||||||
|
if (response.payment_collection) {
|
||||||
|
const url = await handleNavigateToPayment({ language });
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<div className="flex justify-end gap-x-4 px-6 py-4">
|
||||||
|
<div className="mr-[36px]">
|
||||||
|
<p className="ml-0 font-bold text-sm">
|
||||||
|
<Trans i18nKey="cart:total" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mr-[116px]">
|
||||||
|
<p className="text-sm">
|
||||||
|
{formatCurrency({
|
||||||
|
value: cart.total,
|
||||||
|
currencyCode: cart.currency_code,
|
||||||
|
locale: language,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-y-6 py-8">
|
||||||
|
{IS_DISCOUNT_SHOWN && (
|
||||||
|
<Card
|
||||||
|
className="flex flex-col justify-between w-1/2"
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<h5>
|
||||||
|
<Trans i18nKey="cart:discountCode.title" />
|
||||||
|
</h5>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DiscountCode cart={{ ...cart }} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button className="h-10" onClick={handlePayment}>
|
||||||
|
<Trans i18nKey="cart:checkout.goToCheckout" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { 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';
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
LOADING = 'LOADING',
|
||||||
|
ERROR = 'ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MontonioCheckoutCallback() {
|
||||||
|
const [status, setStatus] = useState<Status>(Status.LOADING);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get('order-token');
|
||||||
|
if (!token) {
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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') {
|
||||||
|
await placeOrder();
|
||||||
|
} else {
|
||||||
|
setStatus(Status.ERROR);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error verifying token", e);
|
||||||
|
setStatus(Status.ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void verifyToken();
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
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 null;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
|||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
import { PackageHeader } from '@/components/package-header';
|
import { PackageHeader } from '@/components/package-header';
|
||||||
import { InfoTooltip } from '@/components/ui/info-tooltip';
|
import { InfoTooltip } from '@/components/ui/info-tooltip';
|
||||||
|
import { StoreProduct } from '@medusajs/types';
|
||||||
|
|
||||||
const dummyCards = [
|
const dummyCards = [
|
||||||
{
|
{
|
||||||
@@ -105,8 +106,10 @@ const CheckWithBackground = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ComparePackagesModal = async ({
|
const ComparePackagesModal = async ({
|
||||||
|
analysisPackages,
|
||||||
triggerElement,
|
triggerElement,
|
||||||
}: {
|
}: {
|
||||||
|
analysisPackages: StoreProduct[];
|
||||||
triggerElement: JSX.Element;
|
triggerElement: JSX.Element;
|
||||||
}) => {
|
}) => {
|
||||||
const { t, language } = await createI18nServerInstance();
|
const { t, language } = await createI18nServerInstance();
|
||||||
@@ -140,21 +143,25 @@ const ComparePackagesModal = async ({
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead></TableHead>
|
<TableHead></TableHead>
|
||||||
{dummyCards.map(
|
{analysisPackages.map(
|
||||||
({ titleKey, price, nrOfAnalyses, tagColor }) => (
|
(product) => {
|
||||||
<TableHead key={titleKey} className="py-2">
|
const variant = product.variants?.[0];
|
||||||
<PackageHeader
|
const titleKey = product.title;
|
||||||
title={t(titleKey)}
|
const price = variant?.calculated_price?.calculated_amount ?? 0;
|
||||||
tagColor={tagColor}
|
return (
|
||||||
analysesNr={t('product:nrOfAnalyses', {
|
<TableHead key={titleKey} className="py-2">
|
||||||
nr: nrOfAnalyses,
|
<PackageHeader
|
||||||
})}
|
title={t(titleKey)}
|
||||||
language={language}
|
tagColor='bg-cyan'
|
||||||
price={price}
|
analysesNr={t('product:nrOfAnalyses', {
|
||||||
/>
|
nr: product?.metadata?.nrOfAnalyses ?? 0,
|
||||||
</TableHead>
|
})}
|
||||||
),
|
language={language}
|
||||||
)}
|
price={price}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { ShoppingCart } from 'lucide-react';
|
||||||
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '~/components/app-logo';
|
||||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||||
import { Search } from '~/components/ui/search';
|
import { Search } from '~/components/ui/search';
|
||||||
|
import { SIDEBAR_WIDTH_PROPERTY } from '@/packages/ui/src/shadcn/constants';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
|
||||||
import { SIDEBAR_WIDTH_PROPERTY } from '../../../../packages/ui/src/shadcn/constants';
|
|
||||||
// home imports
|
|
||||||
import { UserNotifications } from '../_components/user-notifications';
|
import { UserNotifications } from '../_components/user-notifications';
|
||||||
import { type UserWorkspace } from '../_lib/server/load-user-workspace';
|
import { type UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||||
import { Button } from '@kit/ui/button';
|
import { StoreCart } from '@medusajs/types';
|
||||||
import { ShoppingCart } from 'lucide-react';
|
import { formatCurrency } from '@/packages/shared/src/utils';
|
||||||
|
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||||
|
|
||||||
export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
|
export async function HomeMenuNavigation(props: { workspace: UserWorkspace, cart: StoreCart | null }) {
|
||||||
|
const { language } = await createI18nServerInstance();
|
||||||
const { workspace, user, accounts } = props.workspace;
|
const { workspace, user, accounts } = props.workspace;
|
||||||
|
const totalValue = props.cart?.total ? formatCurrency({
|
||||||
|
currencyCode: props.cart.currency_code,
|
||||||
|
locale: language,
|
||||||
|
value: props.cart.total,
|
||||||
|
}) : 0;
|
||||||
|
|
||||||
|
const cartItemsCount = props.cart?.items?.length ?? 0;
|
||||||
|
const hasCartItems = cartItemsCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
|
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
|
||||||
@@ -27,13 +38,17 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end gap-3">
|
||||||
<Button className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' variant='ghost'>
|
{hasCartItems && (
|
||||||
<span className='flex items-center text-nowrap'>€ 231,89</span>
|
<Button className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' variant='ghost'>
|
||||||
</Button>
|
<span className='flex items-center text-nowrap'>{totalValue}</span>
|
||||||
<Button variant="ghost" className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' >
|
</Button>
|
||||||
<ShoppingCart className="stroke-[1.5px]" />
|
)}
|
||||||
<Trans i18nKey="common:shoppingCart" /> (0)
|
<Link href='/home/cart'>
|
||||||
</Button>
|
<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})
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<UserNotifications userId={user.id} />
|
<UserNotifications userId={user.id} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
82
app/home/(user)/_components/order/cart-totals.tsx
Normal file
82
app/home/(user)/_components/order/cart-totals.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { formatCurrency } from "@/packages/shared/src/utils"
|
||||||
|
import { StoreOrder } from "@medusajs/types"
|
||||||
|
import React from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export default function CartTotals({ order }: {
|
||||||
|
order: StoreOrder
|
||||||
|
}) {
|
||||||
|
const { i18n: { language } } = useTranslation()
|
||||||
|
const {
|
||||||
|
currency_code,
|
||||||
|
total,
|
||||||
|
subtotal,
|
||||||
|
tax_total,
|
||||||
|
discount_total,
|
||||||
|
gift_card_total,
|
||||||
|
} = order
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col gap-y-2 txt-medium text-ui-fg-subtle ">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex gap-x-1 items-center">
|
||||||
|
<Trans i18nKey="cart:orderConfirmed.subtotal" />
|
||||||
|
</span>
|
||||||
|
<span data-testid="cart-subtotal" data-value={subtotal || 0}>
|
||||||
|
{formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!!discount_total && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span><Trans i18nKey="cart:orderConfirmed.discount" /></span>
|
||||||
|
<span
|
||||||
|
className="text-ui-fg-interactive"
|
||||||
|
data-testid="cart-discount"
|
||||||
|
data-value={discount_total || 0}
|
||||||
|
>
|
||||||
|
-{" "}
|
||||||
|
{formatCurrency({ value: discount_total ?? 0, currencyCode: currency_code, locale: language })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="flex gap-x-1 items-center ">
|
||||||
|
<Trans i18nKey="cart:orderConfirmed.taxes" />
|
||||||
|
</span>
|
||||||
|
<span data-testid="cart-taxes" data-value={tax_total || 0}>
|
||||||
|
{formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!!gift_card_total && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span><Trans i18nKey="cart:orderConfirmed.giftCard" /></span>
|
||||||
|
<span
|
||||||
|
className="text-ui-fg-interactive"
|
||||||
|
data-testid="cart-gift-card-amount"
|
||||||
|
data-value={gift_card_total || 0}
|
||||||
|
>
|
||||||
|
-{" "}
|
||||||
|
{formatCurrency({ value: gift_card_total ?? 0, currencyCode: currency_code, locale: language })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-full border-b border-gray-200 my-4" />
|
||||||
|
<div className="flex items-center justify-between text-ui-fg-base mb-2 txt-medium ">
|
||||||
|
<span><Trans i18nKey="cart:orderConfirmed.total" /></span>
|
||||||
|
<span
|
||||||
|
className="txt-xlarge-plus"
|
||||||
|
data-testid="cart-total"
|
||||||
|
data-value={total || 0}
|
||||||
|
>
|
||||||
|
{formatCurrency({ value: total ?? 0, currencyCode: currency_code, locale: language })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-full border-b border-gray-200 mt-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
app/home/(user)/_components/order/order-completed.tsx
Normal file
24
app/home/(user)/_components/order/order-completed.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
import { StoreOrder } from "@medusajs/types"
|
||||||
|
|
||||||
|
import CartTotals from "./cart-totals"
|
||||||
|
import OrderDetails from "./order-details"
|
||||||
|
import OrderItems from "./order-items"
|
||||||
|
|
||||||
|
export default async function OrderCompleted({
|
||||||
|
order,
|
||||||
|
}: {
|
||||||
|
order: StoreOrder,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PageBody>
|
||||||
|
<PageHeader title={<Trans i18nKey="cart:orderConfirmed.title" />} />
|
||||||
|
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4 gap-y-6">
|
||||||
|
<OrderDetails order={order} />
|
||||||
|
<OrderItems order={order} />
|
||||||
|
<CartTotals order={order} />
|
||||||
|
</div>
|
||||||
|
</PageBody>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
app/home/(user)/_components/order/order-details.tsx
Normal file
47
app/home/(user)/_components/order/order-details.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { StoreOrder } from "@medusajs/types"
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export default function OrderDetails({ order, showStatus }: {
|
||||||
|
order: StoreOrder
|
||||||
|
showStatus?: boolean
|
||||||
|
}) {
|
||||||
|
const formatStatus = (str: string) => {
|
||||||
|
const formatted = str.split("_").join(" ")
|
||||||
|
|
||||||
|
return formatted.slice(0, 1).toUpperCase() + formatted.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{" "}
|
||||||
|
<span>
|
||||||
|
{new Date(order.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-ui-fg-interactive">
|
||||||
|
<Trans i18nKey="cart:orderConfirmed.orderNumber" />: <span data-testid="order-id">{order.display_id}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{showStatus && (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey="cart:orderConfirmed.orderStatus" />:{" "}
|
||||||
|
<span className="text-ui-fg-subtle">
|
||||||
|
{formatStatus(order.fulfillment_status)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey="cart:orderConfirmed.paymentStatus" />:{" "}
|
||||||
|
<span
|
||||||
|
className="text-ui-fg-subtle "
|
||||||
|
data-testid="order-payment-status"
|
||||||
|
>
|
||||||
|
{formatStatus(order.payment_status)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
app/home/(user)/_components/order/order-item.tsx
Normal file
52
app/home/(user)/_components/order/order-item.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { StoreCartLineItem, StoreOrderLineItem } from "@medusajs/types"
|
||||||
|
import { TableCell, TableRow } from "@kit/ui/table"
|
||||||
|
|
||||||
|
import LineItemOptions from "@modules/common/components/line-item-options"
|
||||||
|
import LineItemPrice from "@modules/common/components/line-item-price"
|
||||||
|
import LineItemUnitPrice from "@modules/common/components/line-item-unit-price"
|
||||||
|
|
||||||
|
export default function OrderItem({ item, currencyCode }: {
|
||||||
|
item: StoreCartLineItem | StoreOrderLineItem
|
||||||
|
currencyCode: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableRow className="w-full" data-testid="product-row">
|
||||||
|
{/* <TableCell className="!pl-0 p-4 w-24">
|
||||||
|
<div className="flex w-16">
|
||||||
|
<Thumbnail thumbnail={item.thumbnail} size="square" />
|
||||||
|
</div>
|
||||||
|
</TableCell> */}
|
||||||
|
|
||||||
|
<TableCell className="text-left">
|
||||||
|
<span
|
||||||
|
className="txt-medium-plus text-ui-fg-base"
|
||||||
|
data-testid="product-name"
|
||||||
|
>
|
||||||
|
{item.product_title}
|
||||||
|
</span>
|
||||||
|
<LineItemOptions variant={item.variant} data-testid="product-variant" />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="!pr-0">
|
||||||
|
<span className="!pr-0 flex flex-col items-end h-full justify-center">
|
||||||
|
<span className="flex gap-x-1 ">
|
||||||
|
<span className="text-ui-fg-muted">
|
||||||
|
{item.quantity}x{" "}
|
||||||
|
</span>
|
||||||
|
<LineItemUnitPrice
|
||||||
|
item={item}
|
||||||
|
style="tight"
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<LineItemPrice
|
||||||
|
item={item}
|
||||||
|
style="tight"
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
app/home/(user)/_components/order/order-items.tsx
Normal file
41
app/home/(user)/_components/order/order-items.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import repeat from "@lib/util/repeat"
|
||||||
|
import { StoreOrder } from "@medusajs/types"
|
||||||
|
import { Table, TableBody } from "@kit/ui/table"
|
||||||
|
|
||||||
|
import Divider from "@modules/common/components/divider"
|
||||||
|
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
|
||||||
|
import OrderItem from "./order-item"
|
||||||
|
import { Heading } from "@kit/ui/heading"
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export default function OrderItems({ order }: {
|
||||||
|
order: StoreOrder
|
||||||
|
}) {
|
||||||
|
const items = order.items
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<Heading level={5} className="flex flex-row text-3xl-regular">
|
||||||
|
<Trans i18nKey="cart:orderConfirmed.summary" />
|
||||||
|
</Heading>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Divider className="!mb-0" />
|
||||||
|
<Table>
|
||||||
|
<TableBody data-testid="products-table">
|
||||||
|
{items?.length
|
||||||
|
? items
|
||||||
|
.sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1)
|
||||||
|
.map((item) => (
|
||||||
|
<OrderItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
currencyCode={order.currency_code}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: repeat(5).map((i) => <SkeletonLineItem key={i} />)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
app/home/(user)/_lib/server/load-analysis-packages.ts
Normal file
36
app/home/(user)/_lib/server/load-analysis-packages.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { listCollections, listProducts, listRegions } from "@lib/data";
|
||||||
|
|
||||||
|
async function countryCodesLoader() {
|
||||||
|
const countryCodes = await listRegions().then((regions) =>
|
||||||
|
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(),
|
||||||
|
);
|
||||||
|
return countryCodes ?? [];
|
||||||
|
}
|
||||||
|
export const loadCountryCodes = cache(countryCodesLoader);
|
||||||
|
|
||||||
|
async function collectionsLoader() {
|
||||||
|
const { collections } = await listCollections({
|
||||||
|
fields: 'id, handle',
|
||||||
|
});
|
||||||
|
return collections ?? [];
|
||||||
|
}
|
||||||
|
export const loadCollections = cache(collectionsLoader);
|
||||||
|
|
||||||
|
async function analysisPackagesLoader() {
|
||||||
|
const [countryCodes, collections] = await Promise.all([loadCountryCodes(), loadCollections()]);
|
||||||
|
const countryCode = countryCodes[0]!;
|
||||||
|
|
||||||
|
const collection = collections.find(({ handle }) => handle === 'analysis-packages');
|
||||||
|
if (!collection) {
|
||||||
|
return { analysisPackages: [], countryCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response } = await listProducts({
|
||||||
|
countryCode,
|
||||||
|
queryParams: { limit: 100, collection_id: collection?.id },
|
||||||
|
});
|
||||||
|
return { analysisPackages: response.products, countryCode };
|
||||||
|
}
|
||||||
|
export const loadAnalysisPackages = cache(analysisPackagesLoader);
|
||||||
@@ -13,6 +13,7 @@ import SelectAnalysisPackages from '@/components/select-analysis-packages';
|
|||||||
import { MedReportLogo } from '../../components/med-report-logo';
|
import { MedReportLogo } from '../../components/med-report-logo';
|
||||||
import pathsConfig from '../../config/paths.config';
|
import pathsConfig from '../../config/paths.config';
|
||||||
import ComparePackagesModal from '../home/(user)/_components/compare-packages-modal';
|
import ComparePackagesModal from '../home/(user)/_components/compare-packages-modal';
|
||||||
|
import { loadAnalysisPackages } from '../home/(user)/_lib/server/load-analysis-packages';
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
const { t } = await createI18nServerInstance();
|
const { t } = await createI18nServerInstance();
|
||||||
@@ -23,6 +24,8 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function SelectPackagePage() {
|
async function SelectPackagePage() {
|
||||||
|
const { analysisPackages, countryCode } = await loadAnalysisPackages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
|
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
|
||||||
<MedReportLogo />
|
<MedReportLogo />
|
||||||
@@ -31,6 +34,7 @@ async function SelectPackagePage() {
|
|||||||
<Trans i18nKey={'marketing:selectPackage'} />
|
<Trans i18nKey={'marketing:selectPackage'} />
|
||||||
</h3>
|
</h3>
|
||||||
<ComparePackagesModal
|
<ComparePackagesModal
|
||||||
|
analysisPackages={analysisPackages}
|
||||||
triggerElement={
|
triggerElement={
|
||||||
<Button variant="secondary" className="gap-2">
|
<Button variant="secondary" className="gap-2">
|
||||||
<Trans i18nKey={'marketing:comparePackages'} />
|
<Trans i18nKey={'marketing:comparePackages'} />
|
||||||
@@ -39,7 +43,7 @@ async function SelectPackagePage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SelectAnalysisPackages />
|
<SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
|
||||||
<Link href={pathsConfig.app.home}>
|
<Link href={pathsConfig.app.home}>
|
||||||
<Button variant="secondary" className="align-center">
|
<Button variant="secondary" className="align-center">
|
||||||
<Trans i18nKey={'marketing:notInterestedInAudit'} />{' '}
|
<Trans i18nKey={'marketing:notInterestedInAudit'} />{' '}
|
||||||
|
|||||||
99
components/select-analysis-package.tsx
Normal file
99
components/select-analysis-package.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from '@kit/ui/card';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { StoreProduct, StoreProductVariant } from '@medusajs/types';
|
||||||
|
import { Button } from '@medusajs/ui';
|
||||||
|
import { handleAddToCart } from '@/lib/services/medusaCart.service';
|
||||||
|
|
||||||
|
import { PackageHeader } from './package-header';
|
||||||
|
import { ButtonTooltip } from './ui/button-tooltip';
|
||||||
|
|
||||||
|
export interface IAnalysisPackage {
|
||||||
|
titleKey: string;
|
||||||
|
price: number;
|
||||||
|
nrOfAnalyses: number | string;
|
||||||
|
tagColor: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectAnalysisPackage({
|
||||||
|
analysisPackage,
|
||||||
|
countryCode,
|
||||||
|
}: {
|
||||||
|
analysisPackage: StoreProduct
|
||||||
|
countryCode: string,
|
||||||
|
}) {
|
||||||
|
const { t, i18n: { language } } = useTranslation();
|
||||||
|
|
||||||
|
const [isAddingToCart, setIsAddingToCart] = useState(false);
|
||||||
|
const handleSelect = async (selectedVariant: StoreProductVariant) => {
|
||||||
|
if (!selectedVariant?.id) return null
|
||||||
|
|
||||||
|
setIsAddingToCart(true);
|
||||||
|
await handleAddToCart({
|
||||||
|
selectedVariant,
|
||||||
|
countryCode,
|
||||||
|
});
|
||||||
|
setIsAddingToCart(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleKey = analysisPackage.title;
|
||||||
|
const nrOfAnalyses = analysisPackage?.metadata?.nrOfAnalyses ?? 0;
|
||||||
|
const description = analysisPackage.description ?? '';
|
||||||
|
const subtitle = analysisPackage.subtitle ?? '';
|
||||||
|
const variant = analysisPackage.variants?.[0];
|
||||||
|
|
||||||
|
if (!variant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = variant.calculated_price?.calculated_amount ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={titleKey}>
|
||||||
|
<CardHeader className="relative">
|
||||||
|
{description && (
|
||||||
|
<ButtonTooltip
|
||||||
|
content={description}
|
||||||
|
className="absolute top-5 right-5 z-10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Image
|
||||||
|
src="/assets/card-image.png"
|
||||||
|
alt="background"
|
||||||
|
width={326}
|
||||||
|
height={195}
|
||||||
|
className="max-h-48 w-full opacity-10"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-center">
|
||||||
|
<PackageHeader
|
||||||
|
title={t(titleKey)}
|
||||||
|
tagColor='bg-cyan'
|
||||||
|
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
|
||||||
|
language={language}
|
||||||
|
price={price}
|
||||||
|
/>
|
||||||
|
<CardDescription>
|
||||||
|
{subtitle}
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button className="w-full" onClick={() => handleSelect(variant)} isLoading={isAddingToCart}>
|
||||||
|
{!isAddingToCart && <Trans i18nKey='order-analysis-package:selectThisPackage' />}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,104 +1,15 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { StoreProduct } from '@medusajs/types';
|
||||||
|
|
||||||
import { PackageHeader } from './package-header';
|
import SelectAnalysisPackage from './select-analysis-package';
|
||||||
import { ButtonTooltip } from './ui/button-tooltip';
|
|
||||||
|
|
||||||
export interface IAnalysisPackage {
|
|
||||||
titleKey: string;
|
|
||||||
price: number;
|
|
||||||
nrOfAnalyses: number | string;
|
|
||||||
tagColor: string;
|
|
||||||
descriptionKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysisPackages = [
|
|
||||||
{
|
|
||||||
titleKey: 'product:standard.label',
|
|
||||||
price: 40,
|
|
||||||
nrOfAnalyses: 4,
|
|
||||||
tagColor: 'bg-cyan',
|
|
||||||
descriptionKey: 'marketing:standard.description',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: 'product:standardPlus.label',
|
|
||||||
price: 85,
|
|
||||||
nrOfAnalyses: 10,
|
|
||||||
|
|
||||||
tagColor: 'bg-warning',
|
|
||||||
descriptionKey: 'product:standardPlus.description',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: 'product:premium.label',
|
|
||||||
price: 140,
|
|
||||||
nrOfAnalyses: '12+',
|
|
||||||
|
|
||||||
tagColor: 'bg-purple',
|
|
||||||
descriptionKey: 'product:premium.description',
|
|
||||||
},
|
|
||||||
] satisfies IAnalysisPackage[];
|
|
||||||
|
|
||||||
export default function SelectAnalysisPackages() {
|
|
||||||
const {
|
|
||||||
t,
|
|
||||||
i18n: { language },
|
|
||||||
} = useTranslation();
|
|
||||||
|
|
||||||
|
export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], countryCode: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-6">
|
<div className="grid grid-cols-3 gap-6">
|
||||||
{analysisPackages.length > 0 ? analysisPackages.map(
|
{analysisPackages.length > 0 ? analysisPackages.map(
|
||||||
(
|
(product) => (
|
||||||
{ titleKey, price, nrOfAnalyses, tagColor, descriptionKey },
|
<SelectAnalysisPackage key={product.title} analysisPackage={product} countryCode={countryCode} />
|
||||||
index,
|
)) : (
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<Card key={index}>
|
|
||||||
<CardHeader className="relative">
|
|
||||||
<ButtonTooltip
|
|
||||||
content="Content pending"
|
|
||||||
className="absolute top-5 right-5 z-10"
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src="/assets/card-image.png"
|
|
||||||
alt="background"
|
|
||||||
width={326}
|
|
||||||
height={195}
|
|
||||||
className="max-h-48 w-full opacity-10"
|
|
||||||
/>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-1 text-center">
|
|
||||||
<PackageHeader
|
|
||||||
title={t(titleKey)}
|
|
||||||
tagColor={tagColor}
|
|
||||||
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
|
|
||||||
language={language}
|
|
||||||
price={price}
|
|
||||||
/>
|
|
||||||
<CardDescription>
|
|
||||||
<Trans i18nKey={descriptionKey} />
|
|
||||||
</CardDescription>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button className="w-full">
|
|
||||||
<Trans i18nKey='order-analysis-package:selectThisPackage' />
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
) : (
|
|
||||||
<h4>
|
<h4>
|
||||||
<Trans i18nKey='order-analysis-package:noPackagesAvailable' />
|
<Trans i18nKey='order-analysis-package:noPackagesAvailable' />
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const defaultI18nNamespaces = [
|
|||||||
'product',
|
'product',
|
||||||
'booking',
|
'booking',
|
||||||
'order-analysis-package',
|
'order-analysis-package',
|
||||||
|
'cart',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
127
lib/services/medusaCart.service.ts
Normal file
127
lib/services/medusaCart.service.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
|
||||||
|
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
|
||||||
|
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { requireUserInServerComponent } from '../server/require-user-in-server-component';
|
||||||
|
|
||||||
|
export async function handleAddToCart({
|
||||||
|
selectedVariant,
|
||||||
|
countryCode,
|
||||||
|
}: {
|
||||||
|
selectedVariant: StoreProductVariant
|
||||||
|
countryCode: string
|
||||||
|
}) {
|
||||||
|
const supabase = getSupabaseServerClient();
|
||||||
|
const user = await requireUserInServerComponent();
|
||||||
|
const account = await loadCurrentUserAccount()
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = 1;
|
||||||
|
const cart = await addToCart({
|
||||||
|
variantId: selectedVariant.id,
|
||||||
|
quantity,
|
||||||
|
countryCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.schema('audit')
|
||||||
|
.from('cart_entries')
|
||||||
|
.insert({
|
||||||
|
variant_id: selectedVariant.id,
|
||||||
|
operation: 'ADD_TO_CART',
|
||||||
|
account_id: account.id,
|
||||||
|
cart_id: cart.id,
|
||||||
|
changed_by: user.id,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
throw new Error('Error logging cart entry: ' + error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleNavigateToPayment({ language }: { language: string }) {
|
||||||
|
const supabase = getSupabaseServerClient();
|
||||||
|
const user = await requireUserInServerComponent();
|
||||||
|
const account = await loadCurrentUserAccount()
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cart = await retrieveCart();
|
||||||
|
if (!cart) {
|
||||||
|
throw new Error("No cart found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = "webhook.site:3000";
|
||||||
|
const proto = "http";
|
||||||
|
// const host = headersList.get('host');
|
||||||
|
// const proto = headersList.get('x-forwarded-proto') ?? 'http';
|
||||||
|
const publicUrl = `${proto}://${host}`;
|
||||||
|
|
||||||
|
const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({
|
||||||
|
notificationUrl: `${publicUrl}/api/billing/webhook`,
|
||||||
|
returnUrl: `${publicUrl}/home/cart/montonio-callback`,
|
||||||
|
amount: cart.total,
|
||||||
|
currency: cart.currency_code.toUpperCase(),
|
||||||
|
description: `Order from Medreport`,
|
||||||
|
locale: language,
|
||||||
|
merchantReference: `${account.id}:${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.schema('audit')
|
||||||
|
.from('cart_entries')
|
||||||
|
.insert({
|
||||||
|
operation: 'NAVIGATE_TO_PAYMENT',
|
||||||
|
account_id: account.id,
|
||||||
|
cart_id: cart.id,
|
||||||
|
changed_by: user.id,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
throw new Error('Error logging cart entry: ' + error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paymentLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleLineItemTimeout({
|
||||||
|
lineItem,
|
||||||
|
}: {
|
||||||
|
lineItem: StoreCartLineItem
|
||||||
|
}) {
|
||||||
|
const supabase = getSupabaseServerClient();
|
||||||
|
const user = await requireUserInServerComponent();
|
||||||
|
const account = await loadCurrentUserAccount()
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineItem.updated_at) {
|
||||||
|
const updatedAt = new Date(lineItem.updated_at);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - updatedAt.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteLineItem(lineItem.id);
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.schema('audit')
|
||||||
|
.from('cart_entries')
|
||||||
|
.insert({
|
||||||
|
operation: 'LINE_ITEM_TIMEOUT',
|
||||||
|
account_id: account.id,
|
||||||
|
cart_id: lineItem.cart_id,
|
||||||
|
changed_by: user.id,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
throw new Error('Error logging cart entry: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,8 @@ export async function addToCart({
|
|||||||
revalidateTag(fulfillmentCacheTag);
|
revalidateTag(fulfillmentCacheTag);
|
||||||
})
|
})
|
||||||
.catch(medusaError);
|
.catch(medusaError);
|
||||||
|
|
||||||
|
return cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLineItem({
|
export async function updateLineItem({
|
||||||
@@ -394,7 +396,7 @@ export async function placeOrder(cartId?: string) {
|
|||||||
const id = cartId || (await getCartId());
|
const id = cartId || (await getCartId());
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("No existing cart found when placing an order");
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -411,17 +413,14 @@ export async function placeOrder(cartId?: string) {
|
|||||||
.catch(medusaError);
|
.catch(medusaError);
|
||||||
|
|
||||||
if (cartRes?.type === "order") {
|
if (cartRes?.type === "order") {
|
||||||
const countryCode =
|
|
||||||
cartRes.order.shipping_address?.country_code?.toLowerCase();
|
|
||||||
|
|
||||||
const orderCacheTag = await getCacheTag("orders");
|
const orderCacheTag = await getCacheTag("orders");
|
||||||
revalidateTag(orderCacheTag);
|
revalidateTag(orderCacheTag);
|
||||||
|
|
||||||
removeCartId();
|
removeCartId();
|
||||||
redirect(`/${countryCode}/order/${cartRes?.order.id}/confirmed`);
|
redirect(`/home/order/${cartRes?.order.id}/confirmed`);
|
||||||
|
} else {
|
||||||
|
throw new Error("Cart is not an order");
|
||||||
}
|
}
|
||||||
|
|
||||||
return cartRes.cart;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const listProducts = async ({
|
|||||||
regionId,
|
regionId,
|
||||||
}: {
|
}: {
|
||||||
pageParam?: number
|
pageParam?: number
|
||||||
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
|
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { collection_id?: string }
|
||||||
countryCode?: string
|
countryCode?: string
|
||||||
regionId?: string
|
regionId?: string
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const getRegion = async (countryCode: string) => {
|
|||||||
|
|
||||||
const region = countryCode
|
const region = countryCode
|
||||||
? regionMap.get(countryCode)
|
? regionMap.get(countryCode)
|
||||||
: regionMap.get("us")
|
: regionMap.get("et")
|
||||||
|
|
||||||
return region
|
return region
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -3,24 +3,34 @@
|
|||||||
import { deleteLineItem } from "@lib/data/cart";
|
import { deleteLineItem } from "@lib/data/cart";
|
||||||
import { Spinner, Trash } from "@medusajs/icons";
|
import { Spinner, Trash } from "@medusajs/icons";
|
||||||
import { clx } from "@medusajs/ui";
|
import { clx } from "@medusajs/ui";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const DeleteButton = ({
|
const DeleteButton = ({
|
||||||
id,
|
id,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
Icon,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
Icon?: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
await deleteLineItem(id).catch((err) => {
|
|
||||||
|
try {
|
||||||
|
await deleteLineItem(id);
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
// TODO: display a toast notification with the error
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +44,7 @@ const DeleteButton = ({
|
|||||||
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
|
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
|
||||||
onClick={() => handleDelete(id)}
|
onClick={() => handleDelete(id)}
|
||||||
>
|
>
|
||||||
{isDeleting ? <Spinner className="animate-spin" /> : <Trash />}
|
{isDeleting ? <Spinner className="animate-spin" /> : (Icon ?? <Trash />)}
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
56
public/locales/en/cart.json
Normal file
56
public/locales/en/cart.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"title": "Cart",
|
||||||
|
"description": "View your cart",
|
||||||
|
"emptyCartMessage": "Your cart is empty",
|
||||||
|
"emptyCartMessageDescription": "Add items to your cart to continue.",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"total": "Total",
|
||||||
|
"table": {
|
||||||
|
"item": "Item",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"price": "Price",
|
||||||
|
"total": "Total"
|
||||||
|
},
|
||||||
|
"checkout": {
|
||||||
|
"goToCheckout": "Go to checkout",
|
||||||
|
"goToDashboard": "Continue",
|
||||||
|
"error": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"description": "Please try again later."
|
||||||
|
},
|
||||||
|
"timeLeft": "Time left {{timeLeft}}",
|
||||||
|
"timeoutTitle": "Reservation expired",
|
||||||
|
"timeoutDescription": "Reservation for {{productTitle}} in shopping cart has expired.",
|
||||||
|
"timeoutAction": "Continue"
|
||||||
|
},
|
||||||
|
"discountCode": {
|
||||||
|
"label": "Add Promotion Code(s)",
|
||||||
|
"apply": "Apply",
|
||||||
|
"subtitle": "If you wish, you can add a promotion code",
|
||||||
|
"placeholder": "Enter promotion code"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"analysisPackages": {
|
||||||
|
"productColumnLabel": "Package name"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"productColumnLabel": "Service name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"orderConfirmed": {
|
||||||
|
"title": "Order confirmed",
|
||||||
|
"summary": "Summary",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"taxes": "Taxes",
|
||||||
|
"giftCard": "Gift card",
|
||||||
|
"total": "Total",
|
||||||
|
"orderDate": "Order date",
|
||||||
|
"orderNumber": "Order number",
|
||||||
|
"orderStatus": "Order status",
|
||||||
|
"paymentStatus": "Payment status"
|
||||||
|
},
|
||||||
|
"montonioCallback": {
|
||||||
|
"title": "Montonio checkout",
|
||||||
|
"description": "Please wait while we process your payment."
|
||||||
|
}
|
||||||
|
}
|
||||||
57
public/locales/et/cart.json
Normal file
57
public/locales/et/cart.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"title": "Ostukorv",
|
||||||
|
"description": "Vaata oma ostukorvi",
|
||||||
|
"emptyCartMessage": "Sinu ostukorv on tühi",
|
||||||
|
"emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.",
|
||||||
|
"subtotal": "Vahesumma",
|
||||||
|
"total": "Summa",
|
||||||
|
"table": {
|
||||||
|
"item": "Toode",
|
||||||
|
"quantity": "Kogus",
|
||||||
|
"price": "Hind",
|
||||||
|
"total": "Summa"
|
||||||
|
},
|
||||||
|
"checkout": {
|
||||||
|
"goToCheckout": "Vormista ost",
|
||||||
|
"goToDashboard": "Jätkan",
|
||||||
|
"error": {
|
||||||
|
"title": "Midagi läks valesti",
|
||||||
|
"description": "Palun proovi hiljem uuesti."
|
||||||
|
},
|
||||||
|
"timeLeft": "Aega jäänud {{timeLeft}}",
|
||||||
|
"timeoutTitle": "Broneering aegus",
|
||||||
|
"timeoutDescription": "Toote {{productTitle}} broneering ostukorvis on aegunud.",
|
||||||
|
"timeoutAction": "Jätkan"
|
||||||
|
},
|
||||||
|
"discountCode": {
|
||||||
|
"title": "Kinkekaart või sooduskood",
|
||||||
|
"label": "Lisa promo kood",
|
||||||
|
"apply": "Rakenda",
|
||||||
|
"subtitle": "Kui soovid, võid lisada promo koodi",
|
||||||
|
"placeholder": "Sisesta promo kood"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"analysisPackages": {
|
||||||
|
"productColumnLabel": "Paketi nimi"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"productColumnLabel": "Teenuse nimi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"orderConfirmed": {
|
||||||
|
"title": "Tellimus on edukalt esitatud",
|
||||||
|
"summary": "Summa",
|
||||||
|
"subtotal": "Vahesumma",
|
||||||
|
"taxes": "Maksud",
|
||||||
|
"giftCard": "Kinkekaart",
|
||||||
|
"total": "Summa",
|
||||||
|
"orderDate": "Tellimuse kuupäev",
|
||||||
|
"orderNumber": "Tellimuse number",
|
||||||
|
"orderStatus": "Tellimuse olek",
|
||||||
|
"paymentStatus": "Makse olek"
|
||||||
|
},
|
||||||
|
"montonioCallback": {
|
||||||
|
"title": "Montonio makseprotsess",
|
||||||
|
"description": "Palun oodake, kuni me töötleme sinu makseprotsessi lõpuni."
|
||||||
|
}
|
||||||
|
}
|
||||||
27
supabase/migrations/20250717075136_audit_cart.sql
Normal file
27
supabase/migrations/20250717075136_audit_cart.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
create table "audit"."cart_entries" (
|
||||||
|
"id" bigint generated by default as identity not null,
|
||||||
|
"account_id" text not null,
|
||||||
|
"cart_id" text not null,
|
||||||
|
"operation" text not null,
|
||||||
|
"variant_id" text,
|
||||||
|
"comment" text,
|
||||||
|
"created_at" timestamp with time zone not null default now(),
|
||||||
|
"changed_by" uuid not null
|
||||||
|
);
|
||||||
|
|
||||||
|
grant usage on schema audit to authenticated;
|
||||||
|
grant select, insert, update, delete on table audit.cart_entries to authenticated;
|
||||||
|
|
||||||
|
alter table "audit"."cart_entries" enable row level security;
|
||||||
|
|
||||||
|
create policy "insert_own"
|
||||||
|
on "audit"."cart_entries"
|
||||||
|
as permissive
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check (auth.uid() = changed_by);
|
||||||
|
|
||||||
|
create policy "service_role_select" on "audit"."cart_entries" for select to service_role using (true);
|
||||||
|
create policy "service_role_insert" on "audit"."cart_entries" for insert to service_role with check (true);
|
||||||
|
create policy "service_role_update" on "audit"."cart_entries" for update to service_role using (true);
|
||||||
|
create policy "service_role_delete" on "audit"."cart_entries" for delete to service_role using (true);
|
||||||
Reference in New Issue
Block a user