MED-193: improve mobile design for cart tables

This commit is contained in:
Danel Kungla
2025-10-08 13:50:04 +03:00
parent 17e7a98534
commit 3a8d73e742
12 changed files with 362 additions and 127 deletions

View File

@@ -186,7 +186,7 @@ const TimeSlots = ({
return (
<Card
className="xs:flex xs:justify-between grid w-full justify-center-safe gap-3 p-4"
className="xs:flex xs:justify-between w-full justify-center-safe gap-3 p-4"
key={index}
>
<div>

View File

@@ -1,7 +1,11 @@
'use client';
import { formatCurrency } from '@/packages/shared/src/utils';
import { StoreCart, StoreCartLineItem } from '@medusajs/types';
import {
StoreCart,
StoreCartLineItem,
StoreCartPromotion,
} from '@medusajs/types';
import { Loader2 } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -30,7 +34,9 @@ export default function CartFormContent({
isInitiatingSession,
getBalanceSummarySelection,
}: {
cart: StoreCart;
cart: StoreCart & {
promotions: StoreCartPromotion[];
};
synlabAnalyses: StoreCartLineItem[];
ttoServiceItems: EnrichedCartItem[];
unavailableLineItemIds?: string[];

View File

@@ -20,7 +20,7 @@ export default function CartItem({
} = useTranslation();
return (
<TableRow className="w-full" data-testid="product-row">
<TableRow className="sm: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"
@@ -41,11 +41,12 @@ export default function CartItem({
</TableCell>
<TableCell className="min-w-[80px] px-4 text-right sm:px-6">
{formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
{item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
</TableCell>
<TableCell className="px-4 text-right sm:px-6">

View File

@@ -10,6 +10,7 @@ import {
import { Trans } from '@kit/ui/trans';
import CartItem from './cart-item';
import MobileCartItems from './mobile-cart-items';
export default function CartItems({
cart,
@@ -25,37 +26,54 @@ export default function CartItems({
}
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.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>
</TableRow>
</TableHeader>
<TableBody>
<>
<Table className="hidden border-separate rounded-lg border sm:block">
<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.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>
</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>
<div className="sm:hidden">
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<CartItem
<MobileCartItems
key={item.id}
item={item}
currencyCode={cart.currency_code}
productColumnLabelKey={productColumnLabelKey}
/>
))}
</TableBody>
</Table>
</div>
</>
);
}

View File

@@ -1,76 +1,26 @@
'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,
setEditingItem,
}: {
item: EnrichedCartItem;
currencyCode: string;
isUnavailable?: boolean;
setEditingItem: (item: EnrichedCartItem | null) => void;
}) {
const [editingItem, setEditingItem] = useState<EnrichedCartItem | null>(null);
const {
i18n: { language },
} = useTranslation();
@@ -106,11 +56,12 @@ export default function CartServiceItem({
</TableCell>
<TableCell className="min-w-[80px] px-4 text-right sm:px-6">
{formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
{item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
</TableCell>
<TableCell className="px-4 text-right sm:px-6">
@@ -137,10 +88,6 @@ export default function CartServiceItem({
</TableCell>
</TableRow>
)}
<EditCartServiceItemModal
item={editingItem}
onComplete={() => setEditingItem(null)}
/>
</>
);
}

View File

@@ -1,5 +1,14 @@
import { useState } from 'react';
import { StoreCart } from '@medusajs/types';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@kit/ui/shadcn/dialog';
import {
Table,
TableBody,
@@ -9,9 +18,52 @@ import {
} from '@kit/ui/table';
import { Trans } from '@kit/ui/trans';
import BookingContainer from '../booking/booking-container';
import CartServiceItem from './cart-service-item';
import MobileCartServiceItems from './mobile-cart-service-items';
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 CartServiceItems({
cart,
items,
@@ -23,50 +75,75 @@ export default function CartServiceItems({
productColumnLabelKey: string;
unavailableLineItemIds?: string[];
}) {
const [editingItem, setEditingItem] = useState<EnrichedCartItem | null>(null);
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>
<>
<Table className="hidden border-separate rounded-lg border sm:block">
<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)}
setEditingItem={setEditingItem}
/>
))}
</TableBody>
</Table>
<div className="sm:hidden">
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<CartServiceItem
<MobileCartServiceItems
key={item.id}
item={item}
currencyCode={cart.currency_code}
isUnavailable={unavailableLineItemIds?.includes(item.id)}
productColumnLabelKey={productColumnLabelKey}
setEditingItem={setEditingItem}
/>
))}
</TableBody>
</Table>
</div>
<EditCartServiceItemModal
item={editingItem}
onComplete={() => setEditingItem(null)}
/>
</>
);
}

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { convertToLocale } from '@lib/util/money';
import { StoreCart, StorePromotion } from '@medusajs/types';
import { StoreCart, StoreCartPromotion } from '@medusajs/types';
import { Badge, Text } from '@medusajs/ui';
import Trash from '@modules/common/icons/trash';
import { useFormContext } from 'react-hook-form';
@@ -24,7 +24,7 @@ export default function DiscountCode({
cart,
}: {
cart: StoreCart & {
promotions: StorePromotion[];
promotions: StoreCartPromotion[];
};
}) {
const { t } = useTranslation('cart');

View File

@@ -4,7 +4,11 @@ import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { StoreCart, StoreCartLineItem } from '@medusajs/types';
import {
StoreCart,
StoreCartLineItem,
StoreCartPromotion,
} from '@medusajs/types';
import { useTranslation } from 'react-i18next';
import { AccountBalanceSummary } from '@kit/accounts/services/account-balance.service';
@@ -23,7 +27,11 @@ export default function Cart({
balanceSummary,
}: {
accountId: string;
cart: StoreCart | null;
cart:
| (StoreCart & {
promotions: StoreCartPromotion[];
})
| null;
synlabAnalyses: StoreCartLineItem[];
ttoServiceItems: EnrichedCartItem[];
balanceSummary: AccountBalanceSummary | null;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { formatCurrency } from '@/packages/shared/src/utils';
import { StoreCartLineItem } from '@medusajs/types';
import { useTranslation } from 'react-i18next';
import { Table, TableBody } from '@kit/ui/shadcn/table';
import MobileCartRow from './mobile-cart-row';
const MobileCartItems = ({
item,
currencyCode,
productColumnLabelKey,
}: {
item: StoreCartLineItem;
currencyCode: string;
productColumnLabelKey: string;
}) => {
const {
i18n: { language },
} = useTranslation();
return (
<Table className="border-separate rounded-lg border p-2">
<TableBody>
<MobileCartRow
titleKey={productColumnLabelKey}
value={item.product_title}
/>
<MobileCartRow titleKey="cart:table.time" value={item.quantity} />
<MobileCartRow
titleKey="cart:table.price"
value={formatCurrency({
value: item.unit_price,
currencyCode,
locale: language,
})}
/>
<MobileCartRow
titleKey="cart:table.total"
value={
item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})
}
/>
</TableBody>
</Table>
);
};
export default MobileCartItems;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Trans } from '@kit/ui/makerkit/trans';
import { TableCell, TableHead, TableRow } from '@kit/ui/shadcn/table';
const MobileCartRow = ({
titleKey,
value,
}: {
titleKey?: string;
value?: string | number;
}) => (
<TableRow>
<TableHead className="h-2 font-bold">
<Trans i18nKey={titleKey} />
</TableHead>
<TableCell className="p-0 text-right">
<p
className="txt-medium-plus text-ui-fg-base"
data-testid="product-title"
>
{value}
</p>
</TableCell>
</TableRow>
);
export default MobileCartRow;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils';
import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import { Table, TableBody, TableCell, TableRow } from '@kit/ui/shadcn/table';
import CartItemDelete from './cart-item-delete';
import MobileCartRow from './mobile-cart-row';
import { EnrichedCartItem } from './types';
const MobileCartServiceItems = ({
item,
currencyCode,
isUnavailable,
productColumnLabelKey,
setEditingItem,
}: {
item: EnrichedCartItem;
currencyCode: string;
isUnavailable?: boolean;
productColumnLabelKey: string;
setEditingItem: (item: EnrichedCartItem | null) => void;
}) => {
const {
i18n: { language },
} = useTranslation();
return (
<Table className="border-separate rounded-lg border p-2">
<TableBody>
<MobileCartRow
titleKey={productColumnLabelKey}
value={item.product_title}
/>
<MobileCartRow
titleKey="cart:table.time"
value={formatDateAndTime(item.reservation.startTime.toString())}
/>
<MobileCartRow
titleKey="cart:table.location"
value={item.reservation.location?.address ?? '-'}
/>
<MobileCartRow titleKey="cart:table.quantity" value={item.quantity} />
<MobileCartRow
titleKey="cart:table.price"
value={formatCurrency({
value: item.unit_price,
currencyCode,
locale: language,
})}
/>
<MobileCartRow
titleKey="cart:table.total"
value={
item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})
}
/>
<TableRow>
<TableCell />
<TableCell className="flex w-full items-center justify-end gap-4 p-0 pt-2">
<CartItemDelete id={item.id} />
<Button onClick={() => setEditingItem(item)}>
<Trans i18nKey="common:change" />
</Button>
</TableCell>
</TableRow>
{isUnavailable && (
<TableRow>
<TableCell
colSpan={8}
className="text-destructive px-4 text-left sm:px-6"
>
<Trans i18nKey="booking:timeSlotUnavailable" />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
};
export default MobileCartServiceItems;

View File

@@ -5,7 +5,7 @@ import { redirect } from 'next/navigation';
import { sdk } from '@lib/config';
import medusaError from '@lib/util/medusa-error';
import { HttpTypes, StoreCart } from '@medusajs/types';
import { HttpTypes, StoreCart, StoreCartPromotion } from '@medusajs/types';
import { getLogger } from '@kit/shared/logger';
@@ -25,7 +25,9 @@ import { getRegion } from './regions';
* @param cartId - optional - The ID of the cart to retrieve.
* @returns The cart object if found, or null if not found.
*/
export async function retrieveCart(cartId?: string) {
export async function retrieveCart(
cartId?: string,
): Promise<(StoreCart & { promotions: StoreCartPromotion[] }) | null> {
const id = cartId || (await getCartId());
if (!id) {