The shell gives you chrome and the gates decide whether to render. This is what
goes between them.

## Page primitives

Four components from `@cosmoops/connect-sdk/ui`, outside the `"use client"`
barrel — so they render from a Server Component with no client bundle cost.

```tsx
import {
  ConnectPageShell,
  ConnectSection,
  ConnectSettingRow,
  ConnectEmptyState,
  ConnectStatusChip,
} from "@cosmoops/connect-sdk/ui";
```

`ConnectPageShell` owns the page title and the gap between blocks, so the
spacing between two sections is not a decision you make per page.
`ConnectSection` is a titled block with room for exactly one action.

```tsx title="app/campaigns/page.tsx"
export default function CampaignsPage() {
  return (
    <ConnectPageShell
      title="Campaigns"
      description="Everything you have sent, and what it did."
      action={<Button variant="primary">New campaign</Button>}
    >
      <ConnectSection title="Active" description="Currently sending.">
        <CampaignTable status="active" />
      </ConnectSection>

      <ConnectSection title="Drafts">
        <CampaignTable status="draft" />
      </ConnectSection>
    </ConnectPageShell>
  );
}
```

<Callout title="One primary action per view">
  `ConnectSection` takes a single `action` on purpose. A section with two
  equally-weighted buttons has no primary action, and neither does the page.
</Callout>

<Preview name="page-shell">

```tsx
<ConnectPageShell title="Contacts" description="Everyone who has ever replied." action={<Button variant="primary">New contact</Button>}>
  <ConnectSection title="Recent" description="The last seven days.">
    <ContactsTable />
  </ConnectSection>
</ConnectPageShell>
```

</Preview>

`ConnectSettingRow` is the row inside a section: a label, what it is for, and
the one control it is about. It stacks on a phone and goes side-by-side from
`sm` up, rather than shrinking two columns until neither reads.

<Preview name="section">

```tsx
<ConnectSection title="Notifications" description="How this workspace hears from Pulse." action={<Button variant="secondary">Test</Button>}>
  <ConnectSettingRow
    title="Delivery failures"
    description="Email the campaign owner when a send bounces."
    action={<Switch defaultSelected><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control></Switch.Content></Switch>}
  />
</ConnectSection>
```

</Preview>

## Empty states are two different facts

"You have none" and "none match this filter" need different words and different
buttons — create the thing, versus undo the filter hiding it.

```tsx
{campaigns.length === 0 && !hasFilters && (
  <ConnectEmptyState
    title="No campaigns yet"
    description="Send your first one and it will show up here."
    action={<Button variant="primary">New campaign</Button>}
  />
)}

{campaigns.length === 0 && hasFilters && (
  <ConnectEmptyState
    title="No campaigns match"
    description="Try widening the date range."
    action={<Button variant="secondary" onPress={clearFilters}>Clear filters</Button>}
  />
)}
```

An unfiltered-looking empty state in front of a filtered list reads as data
loss. It is worth the branch.

<Preview name="empty-state">

```tsx
<ConnectEmptyState
  icon={<Inbox />}
  title="No contacts yet"
  description="They appear here as soon as someone replies."
  action={<Button variant="primary">New contact</Button>}
/>
```

</Preview>

## Billing screens you do not have to build

Three drop-in screens cover most of what a partner would otherwise write twice.

<Tabs items={["Pricing page", "Subscription card", "Usage"]}>

<Tab value="Pricing page">

The whole plan grid, the monthly/annual toggle and the subscribe flow:

```tsx title="app/plans/page.tsx"
import { ConnectPlansPage } from "@cosmoops/connect-sdk/client";

export default function Plans() {
  return <ConnectPlansPage />;
}
```

`ConnectPlans` is the grid alone if you want your own page around it, and
`ConnectSubscribeDialog` is the checkout step on its own.

<Preview name="plans">

```tsx
import { ConnectPlans } from "@cosmoops/connect-sdk/client";

<ConnectPlans />
```

</Preview>

</Tab>

<Tab value="Subscription card">

Current plan, renewal date, trial state and a manage link:

```tsx
import { ConnectSubscriptionCard, ConnectTrialBanner } from "@cosmoops/connect-sdk/client";

<ConnectSection title="Your plan">
  <ConnectTrialBanner />
  <ConnectSubscriptionCard />
</ConnectSection>
```

`ConnectTrialBanner` renders nothing outside a trial, so it is safe to leave
mounted — this preview has no active trial, which is the ordinary case:

<Preview name="trial-banner" />

Together with the subscription card:

<Preview name="subscription">

```tsx
<ConnectSection title="Your plan">
  <ConnectTrialBanner />
  <ConnectSubscriptionCard />
</ConnectSection>
```

</Preview>

</Tab>

<Tab value="Usage">

What is left of a consumable add-on:

```tsx
import { ConnectUsageMeter, ConnectSeatUsage } from "@cosmoops/connect-sdk/client";

<ConnectSection title="Usage this cycle">
  <ConnectUsageMeter addonId="sms-credits" />
  <ConnectSeatUsage />
</ConnectSection>
```

The read half of `billing.consumeAddon` — meter usage on your server, show the
balance here.

<Preview name="usage-meter">

```tsx
import { ConnectUsageMeter } from "@cosmoops/connect-sdk/client";

<ConnectUsageMeter />
```

</Preview>

</Tab>

</Tabs>

## The privacy notice you do not have to write

Every application on Connect needs a page naming a **Data Fiduciary** and the
**grievance officer** a user complains to (DPDP Act 2023, s.13). That party is
the platform operator, not you — your application is a Data Processor on their
notice — so the block is read from Connect rather than retyped into your
environment:

```tsx title="app/privacy/page.tsx"
import { ConnectGrievanceNotice } from "@cosmoops/connect-sdk/client";

export default function PrivacyPage() {
  return (
    <ConnectPageShell title="Privacy" description="What we do with your data.">
      <ConnectSection title="Your data">{/* yours */}</ConnectSection>
      <ConnectGrievanceNotice applicationName="Pulse" />
    </ConnectPageShell>
  );
}
```

<Callout type="warn" title="Do not put this route behind a gate">
  A privacy page is public, and the visitor most likely to be looking for a
  redressal channel is the one who is not signed in. The notice reads without
  a session on purpose — mark the route `gateExempt` in your nav and leave it
  outside `ConnectAccessGate`. A statutory contact behind a paywall or a login
  wall is the shape a regulator objects to.
</Callout>

It renders `null` while loading and `null` on failure rather than a skeleton or
an error box — a red panel in the middle of a legal notice reads as the notice
itself being broken. Pass `title={null}` to drop its heading when the page
already has one, or call `useConnectCompanyProfile()` and lay the fields out
yourself.

<Preview name="grievance-notice">

```tsx
import { ConnectGrievanceNotice } from "@cosmoops/connect-sdk/client";

<ConnectGrievanceNotice applicationName="Pulse" />
```

</Preview>

The endpoint behind it needs the **`company:read`** scope on your API key —
tick it in the Console's Integration tab. [Compliance](/docs/concepts/compliance)
covers what stays yours: consent capture, the ledger, withdrawal, and erasure.

## Status

One component, and a map per status union you own. Both halves of the map are
total, so adding a status to your own domain fails the build until it has been
given a tone and a label — which is the property a `Record<string, ...>` does
not have.

```tsx
import { ConnectStatusChip, type ConnectStatusPresentation } from "@cosmoops/connect-sdk/ui";

type CampaignStatus = "draft" | "sending" | "sent" | "failed";

const CAMPAIGN_STATUS: ConnectStatusPresentation<CampaignStatus> = {
  tone: { draft: "default", sending: "accent", sent: "success", failed: "danger" },
  label: { draft: "Draft", sending: "Sending", sent: "Sent", failed: "Failed" },
};

<ConnectStatusChip status={campaign.status} presentation={CAMPAIGN_STATUS} />
```

<Preview name="status-chip">

```tsx
<ConnectStatusChip status={order.status} presentation={ORDER_STATUS} />
```

</Preview>

## Tables

`ConnectCursorPagination` and `ConnectFilterTabs` pair with the store's cursor
paging — see [KV and data](/docs/guides/caching) for the read side.

```tsx
const [cursor, setCursor] = useState<string | null>(null);
const { data } = useConnectResource(`/data/campaigns?cursor=${cursor ?? ""}`);

<>
  <ConnectFilterTabs
    options={[
      { id: "all", label: "All" },
      { id: "active", label: "Active" },
    ]}
    value={filter}
    onChange={setFilter}
  />
  <CampaignTable rows={data?.items ?? []} />
  <ConnectCursorPagination
    nextCursor={data?.nextCursor ?? null}
    onNext={() => setCursor(data?.nextCursor ?? null)}
  />
</>
```

<Callout type="warn" title="nextCursor is the signal, not an empty page">
  A page can come back shorter than the limit and still have more behind it —
  expired KV entries are filtered after paging. Stop when `nextCursor` is
  `null`, never when `items` is empty.
</Callout>

## Ask AI

RAG-grounded chat against the workspace's own indexed content, in one line:

```tsx
import { ConnectAskAi } from "@cosmoops/connect-sdk/client";

<ConnectAskAi placeholder="Ask about your campaigns…" />
```

The workspace comes from the caller's session, never a prop — which matters
more here than anywhere else, because the answers are *retrieved from that
workspace's index*. A workspace you could pass in would be a read of another
tenant's documents, laundered through a chat reply.