CRM
What to do next today, a keyboard-accessible pipeline board, contacts and the quarter’s forecast.
Account executives and their sales manager. Shell: icon rail and saved views. Open the live preview (on the mock backend). Set up snow-ui and the registries first (installation); the template guides cover other routers than Next.js.
What's included
Pages (registry:page)
Each page is a React component that you mount from a route of your app. It reads the adapter, the routes, the URL's search parameters and the shared state from CrmTemplateProvider, inside CrmShell.
Blocks (registry:block)
Shared items
crm-types (the records, CrmQuery, CrmAdapter), crm-filters (the URL filters the pages and a backend's view counts share: filterDeals, filterContacts, filterTasks, isCurrentView, toSearch), format (createFormat({ …, wholeMoney: true }): money from minor units without zero cents, compact money, dates in a time zone, relative times against the clock; toMinorUnits / toMajorUnits), board-moves (the pure moves of a board: moveCard, stepTarget, arrowStep, cardAfter), navigation, class-names, form-validation, use-mod-key, use-reduced-motion, use-focus-on-change, and the mock data: mock-core and mock-crm (createMockCrmAdapter, MOCK_CRM_NOW, MOCK_CRM_TIME_ZONE, MOCK_CRM_ME). No page or block imports the mock data.
The whole template: crm
@snow-ui-pro/crm is a registry:item without files of its own; its registryDependencies are every page and block above.
Install
npx shadcn@latest add @snow-ui-pro/crm
# Optional: the mock backend, to click through the pages before wiring yours.
npx shadcn@latest add @snow-ui-pro/mock-crmOr only what you need, e.g. npx shadcn@latest add @snow-ui-pro/kanban-board.
npm dependencies the items declare: @dnd-kit/core, @dnd-kit/sortable and @dnd-kit/utilities (the board), react-hook-form (forms, through @holakirr/snow-ui/react-hook-form), @holakirr/snow-ui-charts (the forecast) and @phosphor-icons/react (icons).
Wire it up (Next.js App Router)
The provider holds the adapter and a query cache, so the data and the saved views survive client-side navigation; put it, with the shell, in the layout of the CRM routes. It reads the path and the search string from your router, and updates the search parameters shallowly (filters, the board/list switch, pages) through replaceSearch:
// app/crm/crm-provider.tsx
'use client'
import type { Route } from 'next'
import NextLink from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import type { ReactNode } from 'react'
import { CrmShell, CrmTemplateProvider } from '@/components/snow-ui-pro/templates/crm/CrmTemplate'
import type { LinkComponent } from '@/components/snow-ui-pro/lib/navigation'
import { crmAdapter } from '@/lib/crm' // your CrmAdapter
const Link: LinkComponent = ({ href, ...props }) => (
<NextLink href={href as Route} {...props} />
)
export function CrmProvider({ user, now, children }: { user: { name: string; initials: string }; now: string; children: ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const search = useSearchParams()
return (
<CrmTemplateProvider
adapter={crmAdapter}
navigate={(href) => router.push(href as Route)}
// Next.js keeps useSearchParams in sync with the History API.
replaceSearch={(query) => window.history.replaceState(null, '', `${pathname}${query}`)}
pathname={pathname}
search={search.toString()}
linkAs={Link}
user={user}
now={now}
timeZone="Europe/Lisbon"
locale="en-US"
>
<CrmShell>{children}</CrmShell>
</CrmTemplateProvider>
)
}// app/crm/layout.tsx — the pages read the search parameters: render per request
import { CrmProvider } from './crm-provider'
import { getSession } from '@/lib/session'
export const dynamic = 'force-dynamic'
export default async function Layout({ children }: { children: React.ReactNode }) {
const session = await getSession()
return (
<CrmProvider user={session.user} now={new Date().toISOString()}>
{children}
</CrmProvider>
)
}// app/crm/deals/[id]/page.tsx
import { DealRecordPage } from '@/components/snow-ui-pro/templates/crm/DealRecordPage'
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
return <DealRecordPage id={(await params).id} />
}// app/crm/contacts/[[...id]]/page.tsx — list and detail on one page, so the
// list keeps its scroll position when the selection changes
import { ContactsPage } from '@/components/snow-ui-pro/templates/crm/ContactsPage'
export default async function Page({ params }: { params: Promise<{ id?: string[] }> }) {
return <ContactsPage id={(await params).id?.[0]} />
}TodayPage, DealsPage and ForecastPage take no props. routes defaults to /crm, /crm/deals, /crm/deals/[id], /crm/contacts, /crm/contacts/[id] and /crm/forecast. now is the clock for "today", "overdue" and relative times: pass it from the server, so the server and the browser render the same text.
Density: the user menu switches comfortable / compact; persist it with onDensityChange (a cookie, read on the server into defaultDensity, so the server renders the same spacing). Single-key shortcuts (the task list's j / k / x / e) can be turned off there too (defaultShortcuts, onShortcutsChange). theme / onThemeChange add a theme choice; menuItems adds your own items.
The adapter
interface CrmAdapter {
load(query: CrmQuery): Promise<…> // views, today, deals, deal, contacts, contact, forecast
peek?(query: CrmQuery): … | undefined // optional: data at hand, synchronously
saveView, completeTask, updateTask, createTask, deleteTask,
createDeal, moveDeal, updateDeals, deleteDeals,
addActivity, pinActivity, mergeContacts
}- Reads. A page asks
loadfor one query and shows a skeleton in the shape of its data until it resolves, an error with "Try again" if it rejects.peekis optional: return data you already have (a cache, data the server rendered with) and the page renders it at once, also on the server, without a loading state. Adealorcontactquery resolves withnullwhen the record doesn't exist (the page says so). - Writes. Moves, task changes and the list's edits update the page optimistically, call the adapter, and roll back what they changed with a toast when it rejects; deletes, merges, a lost deal and the record's fields wait for the adapter (a dialog or the field shows the error). After a write the pages re-read (
peek, orloadin the background), so the view counts and other pages follow. - Moves.
moveDeal(id, { stageId, beforeId, lost })places a deal before another one (null: last in the stage) rather than at an index, so a move made in a filtered board lands in the right place among the deals the filters hide. Moving to a lost stage carries the reason. - Saved views are a module and its URL filters (
params); count them with the samefilterDeals/filterContacts/filterTasksthe pages use.
The mock
createMockCrmAdapter({ latencyMs, scenario, failOnce }): a deterministic dataset on a fixed clock (Tuesday 16 June 2026, 09:16 in Lisbon): 10 reps, 2 pipelines (New business: 6 stages with a WIP limit on Negotiation; Renewals: 4), 142 deals, 312 contacts (three of them entered twice, for the merge flow), 40 tasks and today's meetings. Mutations stay in memory.
On the site, ?scenario= and ?fail= (comma-separated) apply on a full page load, e.g. /templates/crm?fail=today.
Drag and drop
The board uses dnd-kit (@dnd-kit/core and @dnd-kit/sortable): a mouse sensor (a 6px move starts a drag, so clicks still open the deal), a touch sensor (a long press, 250ms within 5px, picks a card up, so a swipe over the cards scrolls the page) and the keyboard sensor. With the focus on a card's title link, Space picks the card up, ↑ / ↓ change its position, ← / → its column (along the reading direction: in right-to-left text → goes to the previous column), Space or Enter drops it and Escape cancels. The keyboard sensor places the card with the pure board-moves functions instead of measuring geometry, so each step is exact; the board announces the pick-up, every step, the drop and a cancel ("Bluefin — Expansion 2026: Negotiation, position 2 of 11") in its own live region. Every card also has a "Move to stage" menu (the single-pointer alternative, WCAG 2.5.7), which keeps the focus on the moved card. Dropping in a lost stage asks for the reason first; a move on the board has an "Undo" in its toast, which reads the deal again first and doesn't undo a move when the deal has changed since (someone else moved it). A move that fails puts back only that deal. Cards render again only when their own card, place or drag state changes, not on every pointer move. Below md the board shows one stage at a time (a stage picker); moves between stages use the menu.
Accessibility
Tested with axe (WCAG 2.2 A/AA) on every page at 320, 768 and 1280 pixels in light and dark, in a right-to-left preview, and with keyboard-only flows:
- Landmarks: a skip link, "Modules" and "Views" navigation, one
main; the record's aside is a labelledcomplementaryregion. Oneh1per page; after a client-side navigation it takes the focus. - Live regions are few: the board's announcements, the filter result count and the list's selection count (polite), the library's toasts.
- Focus never drops to the page: completed tasks hand it to the next one, moved cards keep it, a new timeline item takes it, inline edits and dialogs return it.
- Destructive actions (deleting deals and tasks, merging contacts) are confirmed and name what they affect; a lost deal asks for its reason.
- Single-key shortcuts only work inside the task list and can be turned off in the user menu (WCAG 2.1.4); search is ⌘K / Ctrl+K.
- Numbers, dates and money go through
Intlwith the provider's locale and an explicit time zone; emails and phone numbers are isolated in right-to-left text.