feat(MED-100): update cart checkout flow and views
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user