# CartDrawer

> A docked, expandable cart for collection and event pages. It fetches its own summary and details, with React and Vue skins over a shared core.

Status: beta. Added in Widgets 0.1.0. Updated in Widgets 3.7.0, 20 Oct 2026.

A docked, expandable cart for collection and event pages. It has matching React
and Vue skins over one shared core.

- Fetches its own summary and details, then groups items by event
- Locale-aware currency and dates, with a live urgency countdown
- Self-contained focus trap, body scroll lock, and motion

## Live demo

The drawer is `position: fixed` over the whole page, so only the controls
sit in the example below. Use the **React / Vue** tabs to compare the two
skins driven by the same controls and the same mock cart. **Show cart**
docks the summary bar. **Expand cart** opens the full cart. It runs on a
canned cart (no network), so emptying and reopening starts fresh.

In production you render `CartDrawer` once on the page, with no buttons. Users
tap or drag the docked bar to expand it. `createDemoCart` is a docs-only
stub; real apps pass a [cart client](#the-cart-client).

## Installation

```bash
pnpm add @oztix/roadie-widgets
```

Install the peer dependencies for the skin you use.

**React**

```bash
pnpm add react react-dom @oztix/roadie-components @tanstack/react-query motion \
  @number-flow/react @phosphor-icons/react react-focus-lock
```

**Vue**

```bash
pnpm add vue motion @number-flow/vue @phosphor-icons/vue focus-trap
```

### Tailwind setup

The drawer's classes live in the package's compiled output, so Tailwind needs to
scan it. Otherwise the styles never make it into your CSS. Add the CSS imports
for the skin you use to your global stylesheet.

**React.** The React skin renders `@oztix/roadie-components`, so include its CSS
as well:

```css
/* app/globals.css */
@import '@oztix/roadie-core/css';
@import '@oztix/roadie-components/css';
@import '@oztix/roadie-widgets/css';
```

**Vue.** The Vue skin uses core classes only, so it needs no components CSS:

```css
/* app/globals.css */
@import '@oztix/roadie-core/css';
@import '@oztix/roadie-widgets/css';
```

Each package's CSS ships its own `@source`, so Tailwind scans the compiled
classes without you ever writing a `node_modules` path.

## Import

```tsx
// React
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'
```

```ts
// Vue
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/vue'
```

The framework-agnostic core is imported separately:

```ts
import { createCartClient } from '@oztix/roadie-widgets/cart'
```

## The cart client

The drawer never talks to the network directly. You inject a `CartClient`. Use
`createCartClient` to build one from a host plus an optional `fetch`
implementation. It owns the cart endpoints and sends `credentials: 'include'`.

```ts
import { createCartClient } from '@oztix/roadie-widgets/cart'

const cart = createCartClient({
  host: '', // empty string for same-origin
  // fetch, // optional, defaults to globalThis.fetch
})
```

A `CartClient` exposes four methods, so you can supply your own if you need a
custom transport:

```ts
interface CartClient {
  getSummary(collectionId: string): Promise<CartSummary | null>
  getDetails(collectionId: string): Promise<CartDetails | null>
  checkoutUrl(details: Pick<CartDetails, 'extrasUrl'>): string | null
  removeItem(cartId: string, eventId: string): Promise<void>
}
```

The drawer checks two fields from the cart API. `extrasUrl` must be a
same-origin path with one leading slash, or `checkoutUrl` returns `null` and
checkout is unavailable. `imageUrl` must be an absolute http or https URL, or
the event image is dropped.

## Event schedules

Each `CartEvent` formats its own time row from structured fields. Supply UTC
timestamps and venue-local day keys. The shared core renders the rest the same
way in every skin and consumer.

| Fields supplied                     | Time row                   |
| ----------------------------------- | -------------------------- |
| `eventStartAtUtc`                   | `7pm`                      |
| `+ eventEndAtUtc` (same day)        | `7pm to 11pm`              |
| `+ eventEndDateKey` (different day) | `6:30pm to Sun 4 Oct, 9pm` |

Pass `eventDateDisplay` to override the computed schedule with a pre-formatted
string. It's an escape hatch, so prefer the structured fields.

Events are ordered by `eventStartAtUtc` and grouped into days by
`eventDateKey` (`YYYY-MM-DD` at the venue). Times show in `eventTimeZone`,
which must be an IANA id such as `Australia/Brisbane`. Without it, times show
in the viewer's time zone, so supply it whenever venue time matters.

## Reserved seating

Ticket lines carry their seats as **structured data**. The drawer groups them
by section and row and collapses consecutive seats into a range via the shared
`formatSeatRange` helper. React, Vue, and every consumer render seating
identically, with no app-side formatting to drift.

```ts
interface CartSeat {
  section?: string // "Stalls", "Mezzanine", "Booth"
  row?: string // "B"
  seat: string // "12"
}

// on a ticket line:
{ name: 'Reserved Seat', quantity: 2, priceEach: 110, seats: [
  { section: 'Stalls', row: 'B', seat: '11' },
  { section: 'Stalls', row: 'B', seat: '12' },
]}
```

| Seats                    | Badge                      |
| ------------------------ | -------------------------- |
| Stalls B11, B12          | `Stalls B11–12`            |
| Stalls B11, B12, B14     | `Stalls B11–12, 14`        |
| Booth 4 (no row)         | `Booth 4`                  |
| Mezzanine M3 + Circle C5 | `Mezzanine M3 · Circle C5` |

Omit `seats` for general-admission tickets. The seat badge only renders when
seats are present. Add an event in the [live demo](#live-demo) to see the
reserved-seating examples (Tuxedo Possum Cabaret, Velvet Dingo Fest).

## Usage

### React

The React skin uses `@tanstack/react-query` internally, so mount it under a
`QueryClientProvider`. Routing is yours to own, so `onNavigate` is required.

```tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'

import { createCartClient } from '@oztix/roadie-widgets/cart'
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'

const queryClient = new QueryClient()
const cart = createCartClient({ host: '' })

export function CollectionCart({ collectionId }: { collectionId: string }) {
  const router = useRouter()

  return (
    <QueryClientProvider client={queryClient}>
      <CartDrawer
        cart={cart}
        collectionId={collectionId}
        onNavigate={(href) => router.push(href)}
        locale='en-AU'
        currency='AUD'
      />
    </QueryClientProvider>
  )
}
```

### Vue

```vue
<script setup lang="ts">
import { createCartClient } from '@oztix/roadie-widgets/cart'
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/vue'

const cart = createCartClient({ host: '' })

function onNavigate(href: string): void {
  // routing is the consumer's job. There's no silent no-op fallback.
  window.location.assign(href)
}
</script>

<template>
  <CartDrawer
    :cart="cart"
    collection-id="abc123"
    :on-navigate="onNavigate"
    locale="en-AU"
    currency="AUD"
  />
</template>
```

The Vue skin also exports `useRoadieTheme` to mirror the host app's light/dark
mode onto the drawer (which renders to `<body>`).

## Controlling open/close

By default the drawer is uncontrolled. Users tap or drag the docked bar, and it
owns its state (seed it with `initialState`). To open or close it from elsewhere
in your UI (e.g. an inline "View cart" button), control it with `open` +
`onOpenChange`: changing `open` animates the drawer with the same spring as a
tap, and tap/drag report back through `onOpenChange` so you keep them in sync.

```tsx
// React
const [open, setOpen] = useState(false)

;<button onClick={() => setOpen(true)}>View cart</button>
;<CartDrawer open={open} onOpenChange={setOpen} cart={cart} … />
```

```vue
<!-- Vue: v-model:open -->
<button @click="open = true">View cart</button>
<CartDrawer v-model:open="open" :cart="cart" … />
```

## Page layout

The docked drawer writes its height to `--cart-drawer-height` on `<html>`, and
`onHeightChange` reports the same height in pixels. The drawer also floats
above the bottom edge (`bottom-3`, or `bottom-4` from `sm`), so pad the bottom
of your page by both or the last of your content sits under the drawer.

```css
main {
  padding-bottom: calc(var(--cart-drawer-height, 0px) + 0.75rem);
}

@media (width >= 40rem) {
  main {
    padding-bottom: calc(var(--cart-drawer-height, 0px) + 1rem);
  }
}
```

## Mount context

`context` decides what the open-state secondary button ("Browse events") does:

- `'collection'` *(default)* closes the drawer. The collection page is already
  behind it.
- `'event'` navigates to the collection page to browse more events.

The browse target is always built **inside the package** from the
server-trusted `collectionId` and routed through `onNavigate`. Consumers never
supply the browse URL, which removes the open-redirect surface. Pass
`browseHref` only to override the default collection-cart route; unsafe values
fall back to the safe default.

## Forcing a refetch

The drawer caches its summary and details. Bump `refreshKey` after a mutation
elsewhere in the app (for example, adding tickets on the event page) to force a
fresh fetch.

## Expiry

The drawer itself only runs the urgency countdown and fires `onExpire` when the
hold reaches zero. It does **not** render any expiry UI. The full
"hold expiring / hold ended" experience is opt-in, shipped as two exports per
skin that you compose yourself:

- **`useCartExpiry(expiresAtUtc)`** is a headless hook (React) / composable
  (Vue), with no UI of its own. It runs the same one-second countdown as the badge and
  returns `{ remaining, expired, showWarning, dismissWarning }`. `showWarning`
  turns on inside the final urgency band (under two minutes) and is a one-shot
  per `expiresAtUtc`: dismissing hides it until the server extends the hold,
  which re-arms it. `expired` becomes `true` once the hold reaches zero.
- **`CartExpiryDialogs`** is the presentational layer, built on the
  [Dialog](https://ticketsolutionsptyltd.github.io/roadie/components/dialog.md) compound. Given that state it renders two
  `alertdialog`s. The first is a dismissible **"Still here?"** warning (warning
  intent) with a live mm:ss countdown and *Keep browsing* / *Checkout* actions.
  Checkout stays disabled until `checkoutUrl` is ready. The second is a blocking
  **"Your hold has ended"** dialog (danger intent, no light-dismiss) whose only
  exit is *Browse events*.

Trigger each dialog to see it. Switch the **React / Vue** tabs to compare skins.
The warning is light-dismissible (close button, backdrop, or `Escape`); the
expired dialog is blocking, so *Browse events* is the only way out.

Wire them together once, alongside the drawer. In production you derive the
props from `useCartExpiry` (the Vue composable takes a getter so it tracks prop
changes):

```tsx
import {
  CartExpiryDialogs,
  useCartExpiry
} from '@oztix/roadie-widgets/cart-drawer/react'

function CartExpiryLayer({ expiresAtUtc, checkoutUrl, browseHref, onNavigate }) {
  const { remaining, expired, showWarning, dismissWarning } =
    useCartExpiry(expiresAtUtc)

  return (
    <CartExpiryDialogs
      showWarning={showWarning}
      expired={expired}
      remaining={remaining}
      onDismissWarning={dismissWarning}
      checkoutUrl={checkoutUrl}
      browseHref={browseHref}
      onNavigate={onNavigate}
    />
  )
}
```

The Vue skin exposes the same `CartExpiryDialogs` component and `useCartExpiry`
composable from `@oztix/roadie-widgets/cart-drawer/vue`, with identical props.
`CartExpiryModals` remains as a deprecated alias and will be removed in v3.0.0.
`CartExpiryDialogs`' props are documented in the [API reference](#api-reference)
below.

## Accessibility

- **Focus is trapped** while the drawer is open and returns to the trigger on
  close. React uses `react-focus-lock` and Vue uses `focus-trap`.
- **Body scroll is locked** by default while open (`lockBodyScroll`).
- **Escape** closes the drawer.
- Motion **respects `prefers-reduced-motion`**: the open/close and drag
  animations are reduced when the user has requested it.

The Vue skin accepts the same props as the React skin documented below.

## API reference

### CartDrawer

```tsx
import { CartDrawer } from '@oztix/roadie-widgets/cart-drawer/react'
```

- `cart`: `CartClient`. Required. Core cart client (host + fetch injected by the consuming app).
- `collectionId`: `string`. Required.
- `onNavigate`: `(href: string) => void`. Required. REQUIRED — routing is the consumer's job.
- `browseHref`: `string`. Empty-state "Browse" target. Defaults to a safe collectionId-derived path; unsafe values fall back to that default.
- `context`: `"collection" | "event"`. Defaults to `collection`. Mount context for the open-state secondary button. Browse target is package-built from collectionId — never a consumer URL — to avoid open-redirect.
- `locale`: `string`. Required. Locale for currency/date formatting.
- `currency`: `string`. Required. ISO 4217 currency code.
- `refreshKey`: `number`. Bump to force a refetch of summary + details.
- `lockBodyScroll`: `boolean`. Defaults to `true`. Lock body scroll while open. Default true.
- `open`: `boolean`. Controlled open state. When provided, the drawer animates to match it — drive it from your own state so any UI (e.g. a "View cart" button) can open/close it, echoing `onOpenChange` back into it. Omit for uncontrolled (tap/drag) behaviour seeded by `initialState`.
- `initialState`: `"closed" | "open"`. Defaults to `closed`. Uncontrolled initial state when `open` is omitted. Default 'closed'.
- `onOpenChange`: `((open: boolean) => void)`. Fires on every open/close intent (tap, drag, Escape, backdrop, or `open`).
- `onExpire`: `(() => void)`. Fires when the urgency countdown hits expiry.
- `onHeightChange`: `((px: number) => void)`. Reports the docked (closed) drawer height in px.

### CartExpiryDialogs

The hold's expiry dialogs, given `useCartExpiry`'s state: a dismissible "Still here?" warning with a countdown, then a blocking "Your hold has ended" dialog whose only exit is browsing events.

```tsx
import { CartExpiryDialogs } from '@oztix/roadie-widgets/cart-drawer/react'
```

- `showWarning`: `boolean`. Required.
- `expired`: `boolean`. Required.
- `remaining`: `number | null`. Required.
- `onDismissWarning`: `() => void`. Required.
- `checkoutUrl`: `string | null`. Required.
- `browseHref`: `string`. Required.
- `onNavigate`: `(href: string) => void`. Required.

Docs updated 9 Oct 2026.
