Screens
Assembling a page from the shell primitives and the SDK's widgets.
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.
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.
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>
);
}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.
ConnectPageShell
Title, description and the one primary action - in the static shell.
Contacts
Everyone who has ever replied.
Recent
The last seven days.
Your table goes here.
<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>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.
ConnectSection
One titled block, with the single control that acts on it.
Notifications
How this workspace hears from Pulse.
Delivery failures
Email the campaign owner when a send bounces.
Weekly summary
Every Monday, 9am in the workspace’s timezone.
<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>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.
{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.
ConnectEmptyState
The two empty states are different facts, so they are different copy.
No contacts yet
They appear here as soon as someone replies.
<ConnectEmptyState
icon={<Inbox />}
title="No contacts yet"
description="They appear here as soon as someone replies."
action={<Button variant="primary">New contact</Button>}
/>Billing screens you do not have to build
Three drop-in screens cover most of what a partner would otherwise write twice.
The whole plan grid, the monthly/annual toggle and the subscribe flow:
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.
ConnectPlans
Your catalogue, grouped by tier, with the cadence toggle.
import { ConnectPlans } from "@cosmoops/connect-sdk/client";
<ConnectPlans />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:
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>
);
}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.
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.
ConnectGrievanceNotice
The platform’s DPDP grievance channel, read rather than retyped.
import { ConnectGrievanceNotice } from "@cosmoops/connect-sdk/client";
<ConnectGrievanceNotice applicationName="Pulse" />The endpoint behind it needs the company:read scope on your API key —
tick it in the Console's Integration tab. 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.
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} />ConnectStatusChip
One presentation map per status union, rendered by one component.
<ConnectStatusChip status={order.status} presentation={ORDER_STATUS} />Tables
ConnectCursorPagination and ConnectFilterTabs pair with the store's cursor
paging — see KV and data for the read side.
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)}
/>
</>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.
Ask AI
RAG-grounded chat against the workspace's own indexed content, in one line:
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.