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
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
| Prop | Type | Description |
|---|---|---|
trigger? | ReactNode | The trigger. Omit for a default button labelled "My carts". |
createAction? | ReactNode | Create action, e.g. a |
renderThumbnails? | ((cart: CartListEntry) => ReactNode) | Per-card thumbnails, keyed by cart id — see |
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.
| Prop | Type | Description |
|---|---|---|
cart | CartListEntry | |
cartCount | number | 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 |
children? | ReactNode | More items for this cart, as |
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.
| Prop | Type | Description |
|---|---|---|
cart | CartListEntry | |
isCurrent? | boolean | Marks this as the current cart. Derived, not a field — there is no
|
ownerName? | string | The owner's name, shown as-is. Pass it only for a colleague's cart: |
actions? | ReactNode | Switch / open action — the caller wires |
thumbnails? | ReactNode | Product thumbnails, up to four with a |
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>.
| Prop | Type | Description |
|---|---|---|
open | boolean | |
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.
| Prop | Type | Description |
|---|---|---|
cart | CartListEntry | |
open | boolean | |
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.
| Prop | Type | Description |
|---|---|---|
cart | CartListEntry |
Any other props go to the root element, like on a plain HTML element.
CartRenameDialogRename a cart.
| Prop | Type | Description |
|---|---|---|
cart | CartListEntry | |
open | boolean | |
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.
| Loading | CartCardSkeleton | Mirrors CartCard's frame — same border, padding and gaps. |
| Loading | CartSwitcherSkeleton | Loading 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} />;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>);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} />;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} />;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>;
