Skip to content

CartSwitcher

"My carts" — a side panel whose stated job is switch carts and create carts, nothing else. Heavier management lives in My Account; keeping the panel to two jobs is what stops it becoming a second cart page.

import { CartSwitcher } from "@evolve-storefront/feature-cart/cart-switcher";

Renders inside CartProvider.

Design notes

A panel rather than a page, on ui's Sheet. Renders nothing in single-cart mode, and its query never runs there either — config, not a fork.

One page, deliberately. BusinessUnit.carts is paginated, and a switcher that paged would be the second cart page this panel exists not to become. The heading shows the total rather than what fits, so a buyer with more carts than a page sees that and goes to My Account; raise pageSize if a project would rather show them all.

The current cart is pinned to the top and marked, by comparing ids against useCurrentCartId — there is no Cart.isCurrent to read. The list is server-filtered by role, so it may contain colleagues' carts — the UI must not assume every cart here is editable.

Preview

SwitcherOpen in Storybook ↗

Usage

The whole panel: open it, switch carts, create one. The pin follows the switch because it is derived from Query.cart — the panel compares ids and the client never holds a current-cart id of its own.

// `CartSwitcher` is connected — it owns `useCarts` and `cartSetCurrent`.
// You supply the trigger and the create action; it renders nothing in single-cart mode.
function CartSwitcherSlot() {
const [creating, setCreating] = useState(false);
return (
<>
<CartSwitcher
createAction={<Button className="w-full" onClick={() => setCreating(true)}>New cart</Button>}
/>
<CartCreateDialog open={creating} onOpenChange={setCreating} />
</>
);
}

Props

PropTypeDescription
trigger?ReactNode

The trigger. Omit for a default button labelled "My carts".

createAction?ReactNode

Create action, e.g. a CartCreateDialog trigger.

renderThumbnails?((cart: CartListEntry) => ReactNode)

Per-card thumbnails, keyed by cart id — see CartCard.

pageSize?number

How many carts the panel asks for. Defaults to the server's 20; the panel renders one page and does not offer a next — see below.

Change the layout

Pass children to CartSwitcher to arrange its parts yourself. Every part is exported from @evolve-storefront/feature-cart/cart-switcher.

const { data: page } = useCarts();
const [renaming, setRenaming] = useState(false);
const [deleting, setDeleting] = useState(false);
<CartName cart={cart} />
<CartActionsMenu
cart={cart}
cartCount={page?.pagination.totalCount ?? page?.items.length ?? 1}
onRename={() => setRenaming(true)}
onClear={() => setClearing(true)}
onDelete={() => setDeleting(true)}
/>
<CartRenameDialog cart={cart} open={renaming} onOpenChange={setRenaming} />
<CartDeleteDialog cart={cart} open={deleting} onOpenChange={setDeleting} />
CartActionsMenuThe kebab behind the cart name: rename, empty, export, delete — the actions for that cart, next to the thing they act on — plus the items a site adds.

Delete only appears when more than one cart exists. Deleting the last one would leave the shopper with nothing and force the server to create a replacement immediately, so it is not offered rather than offered-and-rejected.

PropTypeDescription
cartCartListEntry
cartCountnumber

Total cart count — delete is hidden at one, since a user needs a cart.

onRename?(() => void)
onClear?(() => void)
onDelete?(() => void)
onExport?(() => void)

Downloads the cart's lines, such as useCartCsvExport() with the full cart.

children?ReactNode

More items for this cart, as DropdownMenuItems: what another feature offers, such as saving it as a shopping list or starting a recurring order from it. They sit after the cart's own items and before delete.

onSetDeliveryAddress?deprecated(() => void)
CartCardOne cart in the switcher: name, item count, total, last modified, and the owner when it is a colleague's.

The count is totalQuantity — a sum of quantities, not a count of lines.

The current cart is outlined and labelled — data-current is the styling hook, so a brand restyles the emphasis without forking the card.

PropTypeDescription
cartCartListEntry
isCurrent?boolean

Marks this as the current cart. Derived, not a field — there is no Cart.isCurrent, so the caller compares against useCurrentCartId.

ownerName?string

The owner's name, shown as-is. Pass it only for a colleague's cart: owner is a Customer with no isMe, so "not mine" is owner.id !== customerId and a decision the caller makes — see isColleagueCart and cartOwnerName.

actions?ReactNode

Switch / open action — the caller wires useSetCurrentCart.

thumbnails?ReactNode

Product thumbnails, up to four with a +n chip. Optional and caller-fed: lineItems is unpaginated, so a fifty-cart list that fetched lines to get four images would pull every line of every cart. A bounded Cart.previewImages (limit:) is proposed but unshipped; until it is agreed this strip stays something the caller supplies or omits.

Any other props go to the root element, like on a plain HTML element.

CartCreateDialogCreate a named cart.

Per evolve-dialog-form: the dialog owns its DialogFooter, cancel is a DialogClose, and the submit lives inside the <form>.

PropTypeDescription
openboolean
onOpenChange(open: boolean) => void
CartDeleteDialogDelete a cart.

When the deleted cart is the current one the server promotes a successor and returns it; useDeleteCart renders what comes back rather than picking one, so there is nothing to choose here.

PropTypeDescription
cartCartListEntry
openboolean
onOpenChange(open: boolean) => void
CartNameThe active cart's name as an inline part, so a consumer composes it into their own page heading rather than accepting ours — the page title is the app's and its level depends on a document depth this package cannot know.
PropTypeDescription
cartCartListEntry

Any other props go to the root element, like on a plain HTML element.

CartRenameDialogRename a cart.
PropTypeDescription
cartCartListEntry
openboolean
onOpenChange(open: boolean) => void

Loading and empty states

Render these while the data loads or when there is nothing to show, so the page does not jump when it arrives.

LoadingCartCardSkeletonMirrors CartCard's frame — same border, padding and gaps.
LoadingCartSwitcherSkeletonLoading state for the switcher's card list — the same gap-3 stack.

Examples

VariantsThe three ways a card reads: the current cart (outlined, labelled), a colleague's cart (with the owner's name the caller composes from owner.id, since owner has no display name), and a cart nobody named, labelled with the day it was created.
// `isCurrent` is the caller's answer, not a field on the cart.
<CartCard cart={cart} isCurrent={cart.id === currentCartId} />;
// Pass a name only for someone else's cart: the card renders what it is given.
<CartCard
cart={cart}
ownerName={isColleagueCart(cart, customerId) ? cartOwnerName(cart.owner) : undefined}
/>;
// No name: the card labels it with its creation date.
<CartCard cart={cart} />;
Open in Storybook ↗
Cart ListThe list on its own, for a My Account page rather than the panel — and live, so "Switch to this cart" really switches.
// `data` is one page — `{ items, pagination }`, not an array.
const { data: page, isPending } = useCarts();
const currentCartId = useCurrentCartId();
const setCurrent = useSetCurrentCart();
if (isPending) return <CartSwitcherSkeleton count={3} />;
const ordered = orderCartsCurrentFirst(page?.items ?? [], currentCartId);
return (
<ul className="flex flex-col gap-3">
{ordered.map((cart) => (
<li key={cart.id}>
<CartCard
cart={cart}
isCurrent={cart.id === currentCartId}
actions={
cart.id === currentCartId ? null : (
<Button variant="secondary"
disabled={setCurrent.isPending}
onClick={() => setCurrent.mutate({ cart })}
>
Switch to this cart
</Button>
)
}
/>
</li>
))}
</ul>
);
Open in Storybook ↗
LoadingSkeleton and live list adjacent — the card frames must match.
const { data: page, isPending } = useCarts();
// Gate on isPending, never isLoading: the carts query is auth-gated, and
// `isLoading` is `isPending && isFetching`, so it reports false while gated.
if (isPending) return <CartSwitcherSkeleton count={3} />;
Open in Storybook ↗
Delete CartThe two destructive actions, for real.
const [deleting, setDeleting] = useState(false);
// The server promotes a successor and returns it; render what comes back.
<CartDeleteDialog cart={cart} open={deleting} onOpenChange={setDeleting} />;
Open in Storybook ↗
More ActionsThe cart's own export, and an item another feature adds as a child: saving the cart as a shopping list, here, or starting a recurring order from it.
const { data: cart } = useCart();
const exportCsv = useCartCsvExport();
<CartActionsMenu cart={cart} cartCount={count} onExport={() => exportCsv(cart)}>
<DropdownMenuItem onClick={() => setSavingAsList(true)}>{t("save-as-list")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(newRecurringOrderUrl(cart))}>{t("recurring")}</DropdownMenuItem>
</CartActionsMenu>;
Open in Storybook ↗