From 6426e2a79b055a1e0905c9e956d15ade40fddf2d Mon Sep 17 00:00:00 2001 From: k4rli Date: Thu, 17 Jul 2025 10:16:52 +0300 Subject: [PATCH] feat(MED-100): update cart checkout flow and views --- app/api/montonio/verify-token/route.ts | 97 ++++++++++ app/home/(user)/(dashboard)/cart/loading.tsx | 5 + .../montonio-callback/[montonioId]/page.tsx | 23 +++ app/home/(user)/(dashboard)/cart/page.tsx | 47 +++++ app/home/(user)/(dashboard)/layout.tsx | 4 +- .../order-analysis-package/page.tsx | 6 +- .../order/[orderId]/confirmed/page.tsx | 28 +++ .../(user)/_components/cart/cart-item.tsx | 57 ++++++ .../(user)/_components/cart/cart-items.tsx | 54 ++++++ .../(user)/_components/cart/cart-timer.tsx | 91 ++++++++++ .../(user)/_components/cart/discount-code.tsx | 166 ++++++++++++++++++ app/home/(user)/_components/cart/index.tsx | 111 ++++++++++++ .../cart/montonio-checkout-callback.tsx | 87 +++++++++ .../_components/compare-packages-modal.tsx | 37 ++-- .../_components/home-menu-navigation.tsx | 41 +++-- .../(user)/_components/order/cart-totals.tsx | 82 +++++++++ .../_components/order/order-completed.tsx | 24 +++ .../_components/order/order-details.tsx | 47 +++++ .../(user)/_components/order/order-item.tsx | 52 ++++++ .../(user)/_components/order/order-items.tsx | 41 +++++ .../_lib/server/load-analysis-packages.ts | 36 ++++ app/select-package/page.tsx | 6 +- components/select-analysis-package.tsx | 99 +++++++++++ components/select-analysis-packages.tsx | 101 +---------- lib/i18n/i18n.settings.ts | 1 + lib/services/medusaCart.service.ts | 127 ++++++++++++++ .../medusa-storefront/src/lib/data/cart.ts | 13 +- .../src/lib/data/products.ts | 2 +- .../medusa-storefront/src/lib/data/regions.ts | 2 +- .../common/components/delete-button/index.tsx | 16 +- public/locales/en/cart.json | 56 ++++++ public/locales/et/cart.json | 57 ++++++ .../migrations/20250717075136_audit_cart.sql | 27 +++ 33 files changed, 1505 insertions(+), 138 deletions(-) create mode 100644 app/api/montonio/verify-token/route.ts create mode 100644 app/home/(user)/(dashboard)/cart/loading.tsx create mode 100644 app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/page.tsx create mode 100644 app/home/(user)/(dashboard)/cart/page.tsx create mode 100644 app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx create mode 100644 app/home/(user)/_components/cart/cart-item.tsx create mode 100644 app/home/(user)/_components/cart/cart-items.tsx create mode 100644 app/home/(user)/_components/cart/cart-timer.tsx create mode 100644 app/home/(user)/_components/cart/discount-code.tsx create mode 100644 app/home/(user)/_components/cart/index.tsx create mode 100644 app/home/(user)/_components/cart/montonio-checkout-callback.tsx create mode 100644 app/home/(user)/_components/order/cart-totals.tsx create mode 100644 app/home/(user)/_components/order/order-completed.tsx create mode 100644 app/home/(user)/_components/order/order-details.tsx create mode 100644 app/home/(user)/_components/order/order-item.tsx create mode 100644 app/home/(user)/_components/order/order-items.tsx create mode 100644 app/home/(user)/_lib/server/load-analysis-packages.ts create mode 100644 components/select-analysis-package.tsx create mode 100644 lib/services/medusaCart.service.ts create mode 100644 public/locales/en/cart.json create mode 100644 public/locales/et/cart.json create mode 100644 supabase/migrations/20250717075136_audit_cart.sql diff --git a/app/api/montonio/verify-token/route.ts b/app/api/montonio/verify-token/route.ts new file mode 100644 index 0000000..6898ac6 --- /dev/null +++ b/app/api/montonio/verify-token/route.ts @@ -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, + }, +); \ No newline at end of file diff --git a/app/home/(user)/(dashboard)/cart/loading.tsx b/app/home/(user)/(dashboard)/cart/loading.tsx new file mode 100644 index 0000000..f093295 --- /dev/null +++ b/app/home/(user)/(dashboard)/cart/loading.tsx @@ -0,0 +1,5 @@ +import SkeletonCartPage from '~/medusa/modules/skeletons/templates/skeleton-cart-page'; + +export default function Loading() { + return ; +} diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/page.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/page.tsx new file mode 100644 index 0000000..1c42dcb --- /dev/null +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/page.tsx @@ -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 ( +
+ } /> + + + +
+ ); +} diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx new file mode 100644 index 0000000..cdb0918 --- /dev/null +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -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 ( + + }> + {item && item.updated_at && } + + + + ); +} diff --git a/app/home/(user)/(dashboard)/layout.tsx b/app/home/(user)/(dashboard)/layout.tsx index 281c34c..5e08ec6 100644 --- a/app/home/(user)/(dashboard)/layout.tsx +++ b/app/home/(user)/(dashboard)/layout.tsx @@ -17,6 +17,7 @@ import { HomeMenuNavigation } from '../_components/home-menu-navigation'; import { HomeMobileNavigation } from '../_components/home-mobile-navigation'; import { HomeSidebar } from '../_components/home-sidebar'; import { loadUserWorkspace } from '../_lib/server/load-user-workspace'; +import { retrieveCart } from '@lib/data'; function UserHomeLayout({ children }: React.PropsWithChildren) { const state = use(getLayoutState()); @@ -55,12 +56,13 @@ function SidebarLayout({ children }: React.PropsWithChildren) { function HeaderLayout({ children }: React.PropsWithChildren) { const workspace = use(loadUserWorkspace()); + const cart = use(retrieveCart()); return ( - + diff --git a/app/home/(user)/(dashboard)/order-analysis-package/page.tsx b/app/home/(user)/(dashboard)/order-analysis-package/page.tsx index 7ac0ed4..81ef7e8 100644 --- a/app/home/(user)/(dashboard)/order-analysis-package/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis-package/page.tsx @@ -8,6 +8,7 @@ import { PageBody } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; import ComparePackagesModal from '../../_components/compare-packages-modal'; +import { loadAnalysisPackages } from '../../_lib/server/load-analysis-packages'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); @@ -19,6 +20,8 @@ export const generateMetadata = async () => { }; async function OrderAnalysisPackagePage() { + const { analysisPackages, countryCode } = await loadAnalysisPackages(); + return (
@@ -26,6 +29,7 @@ async function OrderAnalysisPackagePage() { @@ -34,7 +38,7 @@ async function OrderAnalysisPackagePage() { } />
- +
); } diff --git a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx new file mode 100644 index 0000000..95d9e81 --- /dev/null +++ b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx @@ -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 ; +} diff --git a/app/home/(user)/_components/cart/cart-item.tsx b/app/home/(user)/_components/cart/cart-item.tsx new file mode 100644 index 0000000..a25e535 --- /dev/null +++ b/app/home/(user)/_components/cart/cart-item.tsx @@ -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 ( + + +

+ {item.product_title} +

+
+ + + {item.quantity} + + + + {formatCurrency({ + value: item.unit_price, + currencyCode, + locale: language, + })} + + + + {formatCurrency({ + value: item.total, + currencyCode, + locale: language, + })} + + + + + } /> + + +
+ ) +} diff --git a/app/home/(user)/_components/cart/cart-items.tsx b/app/home/(user)/_components/cart/cart-items.tsx new file mode 100644 index 0000000..7ca24a9 --- /dev/null +++ b/app/home/(user)/_components/cart/cart-items.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + {items + .sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1) + .map((item) => ( + + ))} + +
+ ) +} diff --git a/app/home/(user)/_components/cart/cart-timer.tsx b/app/home/(user)/_components/cart/cart-timer.tsx new file mode 100644 index 0000000..22ad5e9 --- /dev/null +++ b/app/home/(user)/_components/cart/cart-timer.tsx @@ -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(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
; + } + + return ( + <> +
+ +
+ + + + + + {t('cart:checkout.timeoutTitle')} + + + {t('cart:checkout.timeoutDescription', { productTitle: cartItem.product?.title })} + + + + setDialogOpen(false)}> + {t('cart:checkout.timeoutAction')} + + + + + + ) +} diff --git a/app/home/(user)/_components/cart/discount-code.tsx b/app/home/(user)/_components/cart/discount-code.tsx new file mode 100644 index 0000000..0a5cbaf --- /dev/null +++ b/app/home/(user)/_components/cart/discount-code.tsx @@ -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>({ + defaultValues: { + code: '', + }, + resolver: zodResolver(DiscountCodeSchema), + }); + + return ( +
+
+ addPromotionCode(data.code))} + className="w-full mb-2 flex gap-x-2" + > + ( + + + + + + )} + /> + + + + + +

+ +

+ + {promotions.length > 0 && ( +
+
+ + Promotion(s) applied: + + + {promotions.map((promotion) => { + return ( +
+ + + + {promotion.code} + {" "} + ( + {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 && ( + + + + )} */} + + + {!promotion.is_automatic && ( + + )} +
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx new file mode 100644 index 0000000..90f7c7d --- /dev/null +++ b/app/home/(user)/_components/cart/index.tsx @@ -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 ( +
+
+
+

+ +

+

+ +

+
+
+
+ ); + } + + 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 ( +
+
+ + +
+ {Array.isArray(cart.items) && cart.items.length > 0 && ( +
+
+

+ +

+
+
+

+ {formatCurrency({ + value: cart.total, + currencyCode: cart.currency_code, + locale: language, + })} +

+
+
+ )} + +
+ {IS_DISCOUNT_SHOWN && ( + + +
+ +
+
+ + + +
+ )} +
+ +
+ +
+
+ ); +} diff --git a/app/home/(user)/_components/cart/montonio-checkout-callback.tsx b/app/home/(user)/_components/cart/montonio-checkout-callback.tsx new file mode 100644 index 0000000..7c29c59 --- /dev/null +++ b/app/home/(user)/_components/cart/montonio-checkout-callback.tsx @@ -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.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 ( +
+ + + + + + +

+ +

+
+
+ +
+ +
+
+ ); + } + + return null; +} diff --git a/app/home/(user)/_components/compare-packages-modal.tsx b/app/home/(user)/_components/compare-packages-modal.tsx index 5f581e7..ca2e2db 100644 --- a/app/home/(user)/_components/compare-packages-modal.tsx +++ b/app/home/(user)/_components/compare-packages-modal.tsx @@ -22,6 +22,7 @@ import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; import { PackageHeader } from '@/components/package-header'; import { InfoTooltip } from '@/components/ui/info-tooltip'; +import { StoreProduct } from '@medusajs/types'; const dummyCards = [ { @@ -105,8 +106,10 @@ const CheckWithBackground = () => { }; const ComparePackagesModal = async ({ + analysisPackages, triggerElement, }: { + analysisPackages: StoreProduct[]; triggerElement: JSX.Element; }) => { const { t, language } = await createI18nServerInstance(); @@ -140,21 +143,25 @@ const ComparePackagesModal = async ({ - {dummyCards.map( - ({ titleKey, price, nrOfAnalyses, tagColor }) => ( - - - - ), - )} + {analysisPackages.map( + (product) => { + const variant = product.variants?.[0]; + const titleKey = product.title; + const price = variant?.calculated_price?.calculated_amount ?? 0; + return ( + + + + ) + })} diff --git a/app/home/(user)/_components/home-menu-navigation.tsx b/app/home/(user)/_components/home-menu-navigation.tsx index cc77954..761be60 100644 --- a/app/home/(user)/_components/home-menu-navigation.tsx +++ b/app/home/(user)/_components/home-menu-navigation.tsx @@ -1,19 +1,30 @@ +import Link from 'next/link'; +import { ShoppingCart } from 'lucide-react'; import { Trans } from '@kit/ui/trans'; - import { AppLogo } from '~/components/app-logo'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; 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 { type UserWorkspace } from '../_lib/server/load-user-workspace'; -import { Button } from '@kit/ui/button'; -import { ShoppingCart } from 'lucide-react'; +import { StoreCart } from '@medusajs/types'; +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 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 (
@@ -27,13 +38,17 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) { />
- - + {hasCartItems && ( + + )} + + +
diff --git a/app/home/(user)/_components/order/cart-totals.tsx b/app/home/(user)/_components/order/cart-totals.tsx new file mode 100644 index 0000000..69dadde --- /dev/null +++ b/app/home/(user)/_components/order/cart-totals.tsx @@ -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 ( +
+
+
+ + + + + {formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })} + +
+ {!!discount_total && ( +
+ + + -{" "} + {formatCurrency({ value: discount_total ?? 0, currencyCode: currency_code, locale: language })} + +
+ )} +
+ + + + + {formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })} + +
+ {!!gift_card_total && ( +
+ + + -{" "} + {formatCurrency({ value: gift_card_total ?? 0, currencyCode: currency_code, locale: language })} + +
+ )} +
+
+
+ + + {formatCurrency({ value: total ?? 0, currencyCode: currency_code, locale: language })} + +
+
+
+ ) +} diff --git a/app/home/(user)/_components/order/order-completed.tsx b/app/home/(user)/_components/order/order-completed.tsx new file mode 100644 index 0000000..cf2cfa6 --- /dev/null +++ b/app/home/(user)/_components/order/order-completed.tsx @@ -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 ( + + } /> +
+ + + +
+
+ ) +} diff --git a/app/home/(user)/_components/order/order-details.tsx b/app/home/(user)/_components/order/order-details.tsx new file mode 100644 index 0000000..e6bc6cf --- /dev/null +++ b/app/home/(user)/_components/order/order-details.tsx @@ -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 ( +
+ + :{" "} + + {new Date(order.created_at).toLocaleDateString()} + + + + : {order.display_id} + + + {showStatus && ( + <> + + :{" "} + + {formatStatus(order.fulfillment_status)} + + + + :{" "} + + {formatStatus(order.payment_status)} + + + + )} +
+ ) +} diff --git a/app/home/(user)/_components/order/order-item.tsx b/app/home/(user)/_components/order/order-item.tsx new file mode 100644 index 0000000..658f91b --- /dev/null +++ b/app/home/(user)/_components/order/order-item.tsx @@ -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 ( + + {/* +
+ +
+
*/} + + + + {item.product_title} + + + + + + + + + {item.quantity}x{" "} + + + + + + + +
+ ) +} diff --git a/app/home/(user)/_components/order/order-items.tsx b/app/home/(user)/_components/order/order-items.tsx new file mode 100644 index 0000000..b08db72 --- /dev/null +++ b/app/home/(user)/_components/order/order-items.tsx @@ -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 ( +
+ + + +
+ + + + {items?.length + ? items + .sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1) + .map((item) => ( + + )) + : repeat(5).map((i) => )} + +
+
+
+ ) +} diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts new file mode 100644 index 0000000..3dc758d --- /dev/null +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -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); diff --git a/app/select-package/page.tsx b/app/select-package/page.tsx index e4de4bb..a446b97 100644 --- a/app/select-package/page.tsx +++ b/app/select-package/page.tsx @@ -13,6 +13,7 @@ import SelectAnalysisPackages from '@/components/select-analysis-packages'; import { MedReportLogo } from '../../components/med-report-logo'; import pathsConfig from '../../config/paths.config'; import ComparePackagesModal from '../home/(user)/_components/compare-packages-modal'; +import { loadAnalysisPackages } from '../home/(user)/_lib/server/load-analysis-packages'; export const generateMetadata = async () => { const { t } = await createI18nServerInstance(); @@ -23,6 +24,8 @@ export const generateMetadata = async () => { }; async function SelectPackagePage() { + const { analysisPackages, countryCode } = await loadAnalysisPackages(); + return (
@@ -31,6 +34,7 @@ async function SelectPackagePage() { @@ -39,7 +43,7 @@ async function SelectPackagePage() { } />
- + + + + ); +} diff --git a/components/select-analysis-packages.tsx b/components/select-analysis-packages.tsx index b5149fe..2f1cfff 100644 --- a/components/select-analysis-packages.tsx +++ b/components/select-analysis-packages.tsx @@ -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 { StoreProduct } from '@medusajs/types'; -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; -} - -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(); +import SelectAnalysisPackage from './select-analysis-package'; +export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], countryCode: string }) { return (
{analysisPackages.length > 0 ? analysisPackages.map( - ( - { titleKey, price, nrOfAnalyses, tagColor, descriptionKey }, - index, - ) => { - return ( - - - - background - - - - - - - - - - - - ); - }, - ) : ( + (product) => ( + + )) : (

diff --git a/lib/i18n/i18n.settings.ts b/lib/i18n/i18n.settings.ts index 22b4768..77e22fc 100644 --- a/lib/i18n/i18n.settings.ts +++ b/lib/i18n/i18n.settings.ts @@ -36,6 +36,7 @@ export const defaultI18nNamespaces = [ 'product', 'booking', 'order-analysis-package', + 'cart', ]; /** diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts new file mode 100644 index 0000000..2813846 --- /dev/null +++ b/lib/services/medusaCart.service.ts @@ -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); + } +} diff --git a/packages/features/medusa-storefront/src/lib/data/cart.ts b/packages/features/medusa-storefront/src/lib/data/cart.ts index 26a9b2b..3ae27c8 100644 --- a/packages/features/medusa-storefront/src/lib/data/cart.ts +++ b/packages/features/medusa-storefront/src/lib/data/cart.ts @@ -154,6 +154,8 @@ export async function addToCart({ revalidateTag(fulfillmentCacheTag); }) .catch(medusaError); + + return cart; } export async function updateLineItem({ @@ -394,7 +396,7 @@ export async function placeOrder(cartId?: string) { const id = cartId || (await getCartId()); if (!id) { - throw new Error("No existing cart found when placing an order"); + return; } const headers = { @@ -411,17 +413,14 @@ export async function placeOrder(cartId?: string) { .catch(medusaError); if (cartRes?.type === "order") { - const countryCode = - cartRes.order.shipping_address?.country_code?.toLowerCase(); - const orderCacheTag = await getCacheTag("orders"); revalidateTag(orderCacheTag); 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; } /** diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index 6a505b1..2ef33ab 100644 --- a/packages/features/medusa-storefront/src/lib/data/products.ts +++ b/packages/features/medusa-storefront/src/lib/data/products.ts @@ -14,7 +14,7 @@ export const listProducts = async ({ regionId, }: { pageParam?: number - queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams + queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { collection_id?: string } countryCode?: string regionId?: string }): Promise<{ diff --git a/packages/features/medusa-storefront/src/lib/data/regions.ts b/packages/features/medusa-storefront/src/lib/data/regions.ts index 4489db5..d724328 100644 --- a/packages/features/medusa-storefront/src/lib/data/regions.ts +++ b/packages/features/medusa-storefront/src/lib/data/regions.ts @@ -57,7 +57,7 @@ export const getRegion = async (countryCode: string) => { const region = countryCode ? regionMap.get(countryCode) - : regionMap.get("us") + : regionMap.get("et") return region } catch (e: any) { diff --git a/packages/features/medusa-storefront/src/modules/common/components/delete-button/index.tsx b/packages/features/medusa-storefront/src/modules/common/components/delete-button/index.tsx index 161e0d6..1d08857 100644 --- a/packages/features/medusa-storefront/src/modules/common/components/delete-button/index.tsx +++ b/packages/features/medusa-storefront/src/modules/common/components/delete-button/index.tsx @@ -3,24 +3,34 @@ import { deleteLineItem } from "@lib/data/cart"; import { Spinner, Trash } from "@medusajs/icons"; import { clx } from "@medusajs/ui"; +import { useRouter } from "next/navigation"; import { useState } from "react"; const DeleteButton = ({ id, children, className, + Icon, }: { id: string; children?: React.ReactNode; className?: string; + Icon?: React.ReactNode; }) => { const [isDeleting, setIsDeleting] = useState(false); + const router = useRouter(); const handleDelete = async (id: string) => { 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); - }); + } }; return ( @@ -34,7 +44,7 @@ const DeleteButton = ({ className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer" onClick={() => handleDelete(id)} > - {isDeleting ? : } + {isDeleting ? : (Icon ?? )} {children}
diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json new file mode 100644 index 0000000..50d34d1 --- /dev/null +++ b/public/locales/en/cart.json @@ -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." + } +} \ No newline at end of file diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json new file mode 100644 index 0000000..42f21a8 --- /dev/null +++ b/public/locales/et/cart.json @@ -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." + } +} \ No newline at end of file diff --git a/supabase/migrations/20250717075136_audit_cart.sql b/supabase/migrations/20250717075136_audit_cart.sql new file mode 100644 index 0000000..f467ad7 --- /dev/null +++ b/supabase/migrations/20250717075136_audit_cart.sql @@ -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);