add cart functionality for tto services

This commit is contained in:
Helena
2025-09-19 16:23:19 +03:00
parent 3c272505d6
commit b59148630a
26 changed files with 921 additions and 221 deletions

View File

@@ -1,22 +1,46 @@
'use client';
import { ServiceCategory } from '../service-categories';
import { StoreProduct } from '@medusajs/types';
import { Trans } from '@kit/ui/trans';
import { EnrichedCartItem } from '../cart/types';
import BookingCalendar from './booking-calendar';
import { BookingProvider } from './booking.provider';
import LocationSelector from './location-selector';
import ServiceSelector from './service-selector';
import TimeSlots from './time-slots';
const BookingContainer = ({ category }: { category: ServiceCategory }) => {
const BookingContainer = ({
category,
cartItem,
onComplete,
}: {
category: { products: StoreProduct[]; countryCode: string };
cartItem?: EnrichedCartItem;
onComplete?: () => void;
}) => {
const products = cartItem?.product ? [cartItem.product] : category.products;
if (!cartItem || !products?.length) {
<p>
<Trans i18nKey="booking:noProducts" />
</p>;
}
return (
<BookingProvider category={category}>
<BookingProvider category={{ products }} service={cartItem?.product}>
<div className="xs:flex-row flex max-h-full flex-col gap-6">
<div className="flex flex-col">
<ServiceSelector products={category.products} />
<ServiceSelector products={products} />
<BookingCalendar />
<LocationSelector />
</div>
<TimeSlots countryCode={category.countryCode} />
<TimeSlots
countryCode={category.countryCode}
cartItem={cartItem}
onComplete={onComplete}
/>
</div>
</BookingProvider>
);

View File

@@ -4,7 +4,6 @@ import { StoreProduct } from '@medusajs/types';
import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service';
import { ServiceCategory } from '../service-categories';
import { BookingContext, Location, TimeSlot } from './booking.context';
export function useBooking() {
@@ -19,10 +18,11 @@ export function useBooking() {
export const BookingProvider: React.FC<{
children: React.ReactElement;
category: ServiceCategory;
}> = ({ children, category }) => {
category: { products: StoreProduct[] };
service?: StoreProduct;
}> = ({ children, category, service }) => {
const [selectedService, setSelectedService] = useState<StoreProduct | null>(
category.products[0] || null,
(service ?? category?.products?.[0]) || null,
);
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(
null,
@@ -32,6 +32,7 @@ export const BookingProvider: React.FC<{
const [locations, setLocations] = useState<Location[] | null>(null);
const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false);
useEffect(() => {
let metadataServiceIds = [];
try {

View File

@@ -18,7 +18,7 @@ import { useBooking } from './booking.provider';
const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
const { selectedService, setSelectedService } = useBooking();
const [collapsed, setCollapsed] = useState(false);
const [firstFourProducts] = useState<StoreProduct[]>(products.slice(0, 4));
const [firstFourProducts] = useState<StoreProduct[]>(products?.slice(0, 4));
const onServiceSelect = async (productId: StoreProduct['id']) => {
const product = products.find((p) => p.id === productId);
@@ -38,7 +38,7 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
className="mb-2 flex flex-col"
onValueChange={onServiceSelect}
>
{firstFourProducts.map((product) => (
{firstFourProducts?.map((product) => (
<div key={product.id} className="flex items-center gap-2">
<RadioGroupItem
value={product.id}
@@ -49,21 +49,23 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => {
</div>
))}
</RadioGroup>
<PopoverTrigger asChild>
<div
onClick={() => setCollapsed((_) => !_)}
className="flex cursor-pointer items-center justify-between border-t py-1"
>
<span>
<Trans i18nKey="booking:showAll" />
</span>
<ChevronDown />
</div>
</PopoverTrigger>
{products.length > 4 && (
<PopoverTrigger asChild>
<div
onClick={() => setCollapsed((_) => !_)}
className="flex cursor-pointer items-center justify-between border-t py-1"
>
<span>
<Trans i18nKey="booking:showAll" />
</span>
<ChevronDown />
</div>
</PopoverTrigger>
)}
</div>
<PopoverContent sideOffset={10}>
<RadioGroup onValueChange={onServiceSelect}>
{products.map((product) => (
{products?.map((product) => (
<div key={product.id + '-2'} className="flex items-center gap-2">
<RadioGroupItem
value={product.id}

View File

@@ -15,7 +15,10 @@ import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { updateReservationTime } from '~/lib/services/reservation.service';
import { createInitialReservationAction } from '../../_lib/server/actions';
import { EnrichedCartItem } from '../cart/types';
import { ServiceProvider, TimeSlot } from './booking.context';
import { useBooking } from './booking.provider';
@@ -32,7 +35,15 @@ const getServiceProviderTitle = (
const PAGE_SIZE = 7;
const TimeSlots = ({ countryCode }: { countryCode: string }) => {
const TimeSlots = ({
countryCode,
cartItem,
onComplete,
}: {
countryCode: string;
cartItem?: EnrichedCartItem;
onComplete?: () => void;
}) => {
const [currentPage, setCurrentPage] = useState(1);
const {
@@ -133,6 +144,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
booking.selectedLocationId ? booking.selectedLocationId : null,
comments,
).then(() => {
if (onComplete) {
onComplete();
}
router.push(pathsConfig.app.cart);
});
@@ -143,6 +157,49 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
});
};
const handleChangeTime = async (
timeSlot: TimeSlot,
reservationId: number,
cartId: string,
) => {
const syncedService = timeSlot.syncedService;
if (!syncedService) {
return toast.error(t('booking:serviceNotFound'));
}
const bookTimePromise = updateReservationTime(
reservationId,
timeSlot.StartTime,
Number(syncedService.id),
timeSlot.UserID,
timeSlot.SyncUserID,
booking.selectedLocationId ? booking.selectedLocationId : null,
cartId,
);
toast.promise(() => bookTimePromise, {
success: <Trans i18nKey={'booking:bookTimeSuccess'} />,
error: <Trans i18nKey={'booking:bookTimeError'} />,
loading: <Trans i18nKey={'booking:bookTimeLoading'} />,
});
if (onComplete) {
onComplete();
}
};
const handleTimeSelect = async (timeSlot: TimeSlot) => {
if (cartItem?.reservation.id) {
return handleChangeTime(
timeSlot,
cartItem.reservation.id,
cartItem.cart_id,
);
}
return handleBookTime(timeSlot);
};
return (
<div className="flex w-full flex-col gap-4">
<div className="flex h-full w-full flex-col gap-2 overflow-auto">
@@ -154,9 +211,12 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
);
const price =
booking.selectedService?.variants?.[0]?.calculated_price
?.calculated_amount;
?.calculated_amount ?? cartItem?.unit_price;
return (
<Card className="grid w-full xs:flex justify-center-safe gap-3 xs:justify-between p-4" key={index}>
<Card
className="xs:flex xs:justify-between grid w-full justify-center-safe gap-3 p-4"
key={index}
>
<div>
<span>{formatDateAndTime(timeSlot.StartTime.toString())}</span>
<div className="flex">
@@ -177,11 +237,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
)}
{isEHIF && <span>{t('booking:ehifBooking')}</span>}
</div>
<div className="flex text-xs">
{timeSlot.location?.address}
</div>
<div className="flex text-xs">{timeSlot.location?.address}</div>
</div>
<div className="flex-end flex items-center justify-between not-last:xs:justify-center gap-2">
<div className="flex-end not-last:xs:justify-center flex items-center justify-between gap-2">
<span className="text-sm font-semibold">
{formatCurrency({
currencyCode: 'EUR',
@@ -189,7 +247,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => {
value: price ?? '',
})}
</span>
<Button onClick={() => handleBookTime(timeSlot)} size="sm">
<Button onClick={() => handleTimeSelect(timeSlot)} size="sm">
<Trans i18nKey="common:book" />
</Button>
</div>

View File

@@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { TableCell, TableRow } from '@kit/ui/table';
import { Trans } from '@kit/ui/trans';
import BookingContainer from '../booking/booking-container';
import CartItemDelete from './cart-item-delete';
import { EnrichedCartItem } from './types';
const EditCartServiceItemModal = ({
item,
onComplete,
}: {
item: EnrichedCartItem | null;
onComplete: () => void;
}) => {
if (!item) return null;
return (
<Dialog defaultOpen>
<DialogContent className="xs:max-w-[90vw] flex max-h-screen max-w-full flex-col items-center gap-4 space-y-4 overflow-y-scroll">
<DialogHeader className="items-center text-center">
<DialogTitle>
<Trans i18nKey="cart:editServiceItem.title" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="cart:editServiceItem.description" />
</DialogDescription>
</DialogHeader>
<div>
{item.product && item.reservation.countryCode ? (
<BookingContainer
category={{
products: [item.product],
countryCode: item.reservation.countryCode,
}}
cartItem={item}
onComplete={onComplete}
/>
) : (
<p>
<Trans i18nKey="booking:noProducts" />
</p>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default function CartServiceItem({
item,
currencyCode,
isUnavailable,
}: {
item: EnrichedCartItem;
currencyCode: string;
isUnavailable?: boolean;
}) {
const [editingItem, setEditingItem] = useState<EnrichedCartItem | null>(null);
const {
i18n: { language },
} = useTranslation();
return (
<>
<TableRow className="w-full" data-testid="product-row">
<TableCell className="w-[100%] px-4 text-left sm:px-6">
<p
className="txt-medium-plus text-ui-fg-base"
data-testid="product-title"
>
{item.product_title}
</p>
</TableCell>
<TableCell className="px-4 sm:px-6">
{formatDateAndTime(item.reservation.startTime.toString())}
</TableCell>
<TableCell className="px-4 sm:px-6">
{item.reservation.location?.address ?? '-'}
</TableCell>
<TableCell className="px-4 sm:px-6">{item.quantity}</TableCell>
<TableCell className="min-w-[80px] px-4 sm:px-6">
{formatCurrency({
value: item.unit_price,
currencyCode,
locale: language,
})}
</TableCell>
<TableCell className="min-w-[80px] px-4 text-right sm:px-6">
{formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
</TableCell>
<TableCell className="px-4 text-right sm:px-6">
<span className="flex justify-end gap-x-1">
<Button size="sm" onClick={() => setEditingItem(item)}>
<Trans i18nKey="common:change" />
</Button>
</span>
</TableCell>
<TableCell className="px-4 text-right sm:px-6">
<span className="flex w-[60px] justify-end gap-x-1">
<CartItemDelete id={item.id} />
</span>
</TableCell>
</TableRow>
{isUnavailable && <TableRow>
<TableCell colSpan={8} className="text-destructive px-4 text-left sm:px-6">
<Trans i18nKey="booking:timeSlotUnavailable" />
</TableCell>
</TableRow>}
<EditCartServiceItemModal
item={editingItem}
onComplete={() => setEditingItem(null)}
/>
</>
);
}

View File

@@ -0,0 +1,72 @@
import { StoreCart } from '@medusajs/types';
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import { Trans } from '@kit/ui/trans';
import CartServiceItem from './cart-service-item';
import { EnrichedCartItem } from './types';
export default function CartServiceItems({
cart,
items,
productColumnLabelKey,
unavailableLineItemIds
}: {
cart: StoreCart;
items: EnrichedCartItem[];
productColumnLabelKey: string;
unavailableLineItemIds?: string[]
}) {
if (!items || items.length === 0) {
return null;
}
return (
<Table className="border-separate rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey={productColumnLabelKey} />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.time" />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.location" />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.quantity" />
</TableHead>
<TableHead className="min-w-[100px] px-4 sm:px-6">
<Trans i18nKey="cart:table.price" />
</TableHead>
<TableHead className="min-w-[100px] px-4 text-right sm:px-6">
<Trans i18nKey="cart:table.total" />
</TableHead>
<TableHead className="px-4 sm:px-6"></TableHead>
<TableHead className="px-4 sm:px-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<CartServiceItem
key={item.id}
item={item}
currencyCode={cart.currency_code}
isUnavailable={unavailableLineItemIds?.includes(item.id)}
/>
))}
</TableBody>
</Table>
);
}

View File

@@ -1,22 +1,23 @@
"use client";
'use client';
import { useState } from 'react';
import { handleNavigateToPayment } from '@/lib/services/medusaCart.service';
import { formatCurrency } from '@/packages/shared/src/utils';
import { initiatePaymentSession } from '@lib/data/cart';
import { StoreCart, StoreCartLineItem } from '@medusajs/types';
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
import CartItems from "./cart-items"
import { Trans } from '@kit/ui/trans';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardHeader,
} from '@kit/ui/card';
import DiscountCode from "./discount-code";
import { initiatePaymentSession } from "@lib/data/cart";
import { formatCurrency } from "@/packages/shared/src/utils";
import { useTranslation } from "react-i18next";
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
import AnalysisLocation from "./analysis-location";
import { Card, CardContent, CardHeader } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import AnalysisLocation from './analysis-location';
import CartItems from './cart-items';
import CartServiceItems from './cart-service-items';
import DiscountCode from './discount-code';
import { EnrichedCartItem } from './types';
const IS_DISCOUNT_SHOWN = true as boolean;
@@ -25,13 +26,16 @@ export default function Cart({
synlabAnalyses,
ttoServiceItems,
}: {
cart: StoreCart | null
cart: StoreCart | null;
synlabAnalyses: StoreCartLineItem[];
ttoServiceItems: StoreCartLineItem[];
ttoServiceItems: EnrichedCartItem[];
}) {
const { i18n: { language } } = useTranslation();
const {
i18n: { language },
} = useTranslation();
const [isInitiatingSession, setIsInitiatingSession] = useState(false);
const [unavailableLineItemIds, setUnavailableLineItemIds] = useState<string[]>()
const items = cart?.items ?? [];
@@ -39,7 +43,10 @@ export default function Cart({
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">
<div
className="flex flex-col items-center justify-center"
data-testid="empty-cart-message"
>
<h4 className="text-center">
<Trans i18nKey="cart:emptyCartMessage" />
</h4>
@@ -60,8 +67,13 @@ export default function Cart({
if (response.payment_collection) {
const { payment_sessions } = response.payment_collection;
const paymentSessionId = payment_sessions![0]!.id;
const url = await handleNavigateToPayment({ language, paymentSessionId });
window.location.href = url;
const result = await handleNavigateToPayment({ language, paymentSessionId });
if (result.url) {
window.location.href = result.url;
}
if (result.unavailableLineItemIds) {
setUnavailableLineItemIds(result.unavailableLineItemIds)
}
} else {
setIsInitiatingSession(false);
}
@@ -71,21 +83,30 @@ export default function Cart({
const isLocationsShown = synlabAnalyses.length > 0;
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
<div className="flex flex-col bg-white gap-y-6">
<CartItems cart={cart} items={synlabAnalyses} productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel" />
<CartItems cart={cart} items={ttoServiceItems} productColumnLabelKey="cart:items.ttoServices.productColumnLabel" />
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 lg:px-4">
<div className="flex flex-col gap-y-6 bg-white">
<CartItems
cart={cart}
items={synlabAnalyses}
productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel"
/>
<CartServiceItems
cart={cart}
items={ttoServiceItems}
productColumnLabelKey="cart:items.ttoServices.productColumnLabel"
unavailableLineItemIds={unavailableLineItemIds}
/>
</div>
{hasCartItems && (
<>
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 pt-2 sm:pt-4">
<div className="w-full sm:w-auto sm:mr-[42px]">
<p className="ml-0 font-bold text-sm text-muted-foreground">
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.subtotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-sm text-right">
<p className="text-right text-sm">
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
@@ -94,14 +115,14 @@ export default function Cart({
</p>
</div>
</div>
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6 py-2 sm:py-4">
<div className="w-full sm:w-auto sm:mr-[42px]">
<p className="ml-0 font-bold text-sm text-muted-foreground">
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.promotionsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-sm text-right">
<p className="text-right text-sm">
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
@@ -110,14 +131,14 @@ export default function Cart({
</p>
</div>
</div>
<div className="flex sm:justify-end gap-x-4 px-4 sm:px-6">
<div className="w-full sm:w-auto sm:mr-[42px]">
<p className="ml-0 font-bold text-sm">
<div className="flex gap-x-4 px-4 sm:justify-end sm:px-6">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.total" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-sm text-right">
<p className="text-right text-sm">
{formatCurrency({
value: cart.total,
currencyCode: cart.currency_code,
@@ -129,11 +150,9 @@ export default function Cart({
</>
)}
<div className="flex sm:flex-row flex-col gap-y-6 py-4 sm:py-8 gap-x-4">
<div className="flex flex-col gap-x-4 gap-y-6 py-4 sm:flex-row sm:py-8">
{IS_DISCOUNT_SHOWN && (
<Card
className="flex flex-col justify-between w-full sm:w-1/2"
>
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:discountCode.title" />
@@ -146,24 +165,31 @@ export default function Cart({
)}
{isLocationsShown && (
<Card
className="flex flex-col justify-between w-full sm:w-1/2"
>
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:locations.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<AnalysisLocation cart={{ ...cart }} synlabAnalyses={synlabAnalyses} />
<AnalysisLocation
cart={{ ...cart }}
synlabAnalyses={synlabAnalyses}
/>
</CardContent>
</Card>
)}
</div>
<div>
<Button className="h-10" onClick={initiatePayment} disabled={isInitiatingSession}>
{isInitiatingSession && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
<Button
className="h-10"
onClick={initiatePayment}
disabled={isInitiatingSession}
>
{isInitiatingSession && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>

View File

@@ -1,15 +1,18 @@
import { StoreCartLineItem } from "@medusajs/types";
import { Reservation } from "~/lib/types/reservation";
export interface MontonioOrderToken {
uuid: string;
accessKey: string;
merchantReference: string;
merchantReferenceDisplay: string;
paymentStatus:
| 'PAID'
| 'FAILED'
| 'CANCELLED'
| 'PENDING'
| 'EXPIRED'
| 'REFUNDED';
| 'PAID'
| 'FAILED'
| 'CANCELLED'
| 'PENDING'
| 'EXPIRED'
| 'REFUNDED';
paymentMethod: string;
grandTotal: number;
currency: string;
@@ -19,4 +22,11 @@ export interface MontonioOrderToken {
paymentLinkUuid: string;
iat: number;
exp: number;
}
}
export enum CartItemType {
analysisOrders = 'analysisOrders',
ttoServices = 'ttoServices',
}
export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation };