Skip to contentVibraUI
Part of the CRM dashboardinstalls at /crm/campaigns

Campaigns

The campaign slate: spend by channel stacked month by month, then every campaign with its spend, clicks, conversions, cost per conversion and conversion rate — a channel facet, and Pause or Resume from the row menu of a campaign that has not ended.

Open the live page

Every row is a db.campaigns record. A campaign records one lifetime spend rather than a ledger, so the chart's months are derived, not seeded: each campaign's spend is spread evenly over the days it ran — to its end, or to REFERENCE_DATE while it runs — and summed by month and channel for the twelve months ending with REFERENCE_DATE's, so a month holds only what was live in it. Cost per conversion and the conversion rate are rules over the row's own spend, clicks and conversions; a campaign with nothing to divide by prints a dash and sorts after every figure in either direction, since a campaign that bought nothing is not the cheapest. The figures and the table share one client island, so pausing a campaign takes it off the live count in the render that turns its pill. The row menu offers the one switch that fits a campaign's state, and an ended campaign, with nothing left to switch, has none. setCampaignStatus is a server action returning Result: it refuses an ended campaign with the day it ended, a campaign already in the asked-for state, a state other than active or paused, and an unknown id; a refusal lands in a danger callout above the table and a success in an always-mounted status line. Composes AppShell, PageHeader, StatCardGroup, StatCard, ChartCard, StackedBarChart, DataTable, DataTableColumnHeader, DataTableRowActions, Badge, StatusBadge, NumberCell, Callout and CampaignsView (block-local).

Preview

Install

npx shadcn@latest add @vibra/crm-campaigns

Needs the @vibra registry in your components.json — set it up once.

Source

app/crm/campaigns/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { CampaignsView } from "./components/campaigns-view"
import { campaignRows, currentUser, lastUpdated, monthlySpend, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/crm/nav"

/**
 * The campaign slate. A server component: it reads `db.campaigns`, spreads
 * each campaign's spend over the months it ran for the chart, and hands the
 * rows to the island that counts them, filters them and switches them.
 */
export default async function CampaignsPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.campaigns}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        titleId="crm-campaigns-title"
        title="Campaigns"
        description="What each campaign spent, what it brought back, and which ones are still running."
        meta={lastUpdated()}
      />

      <CampaignsView titleId="crm-campaigns-title" rows={campaignRows()} months={monthlySpend()} />
    </AppShell>
  )
}
app/crm/campaigns/data.ts
/**
 * What the campaigns page reads: every `db.campaigns` row, and the monthly
 * spend behind the chart. A campaign records one lifetime spend, not a
 * ledger, so the months are derived rather than seeded: each campaign's spend
 * is spread evenly over the days it actually ran — from its start to its end,
 * or to "now" while it is still running — and summed by month and channel.
 * Nothing is invented and nothing is lost inside the window: the months add
 * up to what the campaigns spent in them. "Now" is `REFERENCE_DATE`.
 */
import { formatDate, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Campaign, type Member } from "@/lib/sample-data"

const DAY_MS = 86_400_000

/** How many months the chart covers, ending with the month "now" falls in. */
export const MONTHS = 12

/** The channels in the order the chart stacks them — the islands' vocabulary lists them the same way. */
const CHANNELS: Campaign["channel"][] = ["search", "social", "email", "referral", "organic"]

/** One row of the campaigns table. */
export type CampaignRow = {
  id: string
  name: string
  channel: Campaign["channel"]
  status: Campaign["status"]
  /** Lifetime spend in whole dollars. */
  spend: number
  clicks: number
  conversions: number
  startedAt: Date
  endedAt?: Date
}

/** Every campaign, biggest spend first; the table filters, sorts and pages them itself. */
export function campaignRows(): CampaignRow[] {
  return db.campaigns
    .all()
    .sort((a, b) => b.spendCents - a.spendCents)
    .map((campaign) => ({
      id: campaign.id,
      name: campaign.name,
      channel: campaign.channel,
      status: campaign.status,
      spend: campaign.spendCents / 100,
      clicks: campaign.clicks,
      conversions: campaign.conversions,
      startedAt: campaign.startedAt,
      ...(campaign.endedAt ? { endedAt: campaign.endedAt } : {}),
    }))
}

/** One month of the chart: "2026-09", and the dollars each channel spent in it. */
export type MonthSpend = { month: string } & Record<Campaign["channel"], number>

/** The `MONTHS` months ending with the one "now" falls in, oldest first, as "YYYY-MM". */
function monthKeys(): string[] {
  const current = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), 1)
  return Array.from({ length: MONTHS }, (_, index) => {
    const start = new Date(current)
    start.setUTCMonth(start.getUTCMonth() - (MONTHS - 1 - index))
    return start.toISOString().slice(0, 7)
  })
}

/** Spend by month and channel, each campaign spread evenly over the days it ran. */
export function monthlySpend(): MonthSpend[] {
  const keys = monthKeys()
  const totals = new Map(keys.map((month) => [month, Object.fromEntries(CHANNELS.map((channel) => [channel, 0]))]))

  for (const campaign of db.campaigns.all()) {
    const from = campaign.startedAt.getTime()
    const to = (campaign.endedAt ?? REFERENCE_DATE).getTime()
    const days = Math.max(1, Math.ceil((to - from) / DAY_MS))
    const daily = campaign.spendCents / 100 / days
    for (let day = 0; day < days; day++) {
      const month = totals.get(new Date(from + day * DAY_MS).toISOString().slice(0, 7))
      if (month) month[campaign.channel] += daily
    }
  }

  return keys.map((month) => {
    const spend = totals.get(month)!
    return {
      month,
      ...(Object.fromEntries(CHANNELS.map((channel) => [channel, Math.round(spend[channel])])) as Record<
        Campaign["channel"],
        number
      >),
    }
  })
}

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/crm/campaigns/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { formatDate } from "@/lib/format"
import { db, type Campaign, type Result } from "@/lib/sample-data"

/**
 * What the campaigns page changes. Every answer is a `Result`, and the name in
 * it is the campaign's own, read off the row this action just looked up.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

export type CampaignSwitch = Extract<Campaign["status"], "active" | "paused">

const VERB: Record<CampaignSwitch, string> = { active: "resumed", paused: "paused" }

/**
 * Pauses a live campaign or resumes a paused one. A campaign that has ended
 * cannot be switched either way — its budget is spent and its dates are
 * history — and one already in the asked-for state is refused rather than
 * written twice, so two people pressing the same button hear who was first.
 */
export async function setCampaignStatus(
  id: string,
  status: CampaignSwitch
): Promise<Result<{ id: string; name: string; status: CampaignSwitch }>> {
  // The type is erased at the action boundary; only the two words the menu offers may be written.
  if (status !== "active" && status !== "paused") {
    return { ok: false, error: { code: "invalid", message: `"${String(status)}" is not a state a campaign can be switched to.` } }
  }

  const campaign = await db.campaigns.get(id)
  if (!campaign) return { ok: false, error: { code: "not_found", message: `No campaign with id "${id}".` } }

  if (campaign.status === "completed") {
    const ended = campaign.endedAt ? formatDate(campaign.endedAt, "medium", { timeZone: "UTC" }) : "its end date"
    return {
      ok: false,
      error: { code: "ended", message: `${campaign.name} ended on ${ended}; a finished campaign cannot be ${VERB[status]}.` },
    }
  }
  if (campaign.status === status) {
    return {
      ok: false,
      error: { code: "unchanged", message: `${campaign.name} is already ${status === "active" ? "live" : "paused"}.` },
    }
  }

  const updated = await db.campaigns.update(id, { status })
  if (!updated.ok) return updated
  return { ok: true, data: { id, name: updated.data.name, status } }
}
app/crm/campaigns/components/campaign-columns.tsx
"use client"

import { PauseIcon, PlayIcon } from "lucide-react"

import { formatCurrency, formatDate, formatPercent } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import {
  DataTableColumnHeader,
  DataTableRowActions,
  type DataTableColumnDef,
} from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { NumberCell } from "@/components/ui/table-cells"

import { type CampaignRow } from "../data"
import { CHANNEL_LABELS, STATUS_LABELS, STATUS_MAP } from "./campaigns-vocabulary"

const day = (date: Date) => formatDate(date, "medium", { timeZone: "UTC" })

/**
 * Columns step in as the table's own frame allows — a container query, not
 * the viewport, because at 768 and 1024 the sidebar leaves the table less
 * room than the window suggests. Below 34rem: the campaign, with its status
 * under the name, its spend and its menu; then the status column, the
 * conversions, the cost and the channel, the clicks, and last the rate.
 */
const FROM = {
  status: "hidden @min-[34rem]/campaigns:table-cell",
  conversions: "hidden @min-[42rem]/campaigns:table-cell",
  cost: "hidden @min-[54rem]/campaigns:table-cell",
  clicks: "hidden @min-[59rem]/campaigns:table-cell",
  rate: "hidden @min-[67rem]/campaigns:table-cell",
} as const

/** "Mar 27, 2026 – Aug 13, 2026", or "Since Jun 4, 2026" for one still running. */
function span(row: CampaignRow): string {
  return row.endedAt ? `${day(row.startedAt)} – ${day(row.endedAt)}` : `Since ${day(row.startedAt)}`
}

/**
 * The slate's columns. Cost per conversion and the conversion rate are rules
 * over the row's own spend, clicks and conversions, never stored; where one
 * has nothing to divide by, the cell prints a dash and the row sorts after
 * every figure, whichever way the column is sorted — a campaign that bought
 * nothing is not the cheapest. The row menu offers the one switch that fits
 * the campaign's state, and an ended campaign, with nothing left to switch,
 * has no menu; the server still refuses to resume one.
 */
export function campaignColumns({
  onSwitch,
}: {
  onSwitch: (row: CampaignRow, status: "active" | "paused") => void
}): DataTableColumnDef<CampaignRow>[] {
  return [
    {
      accessorKey: "name",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Campaign" />,
      cell: ({ row }) => (
        <div className="flex min-w-0 max-w-[11rem] flex-col @min-[34rem]/campaigns:max-w-none">
          <span className="truncate font-medium">{row.original.name}</span>
          <span className="text-xs text-muted-foreground tabular-nums @min-[34rem]/campaigns:whitespace-nowrap">
            {/* While the Status column is away, the status leads the line under the name. */}
            <span className="font-medium text-foreground @min-[34rem]/campaigns:hidden">
              {STATUS_LABELS[row.original.status]} ·{" "}
            </span>
            {span(row.original)}
          </span>
        </div>
      ),
      meta: { label: "Campaign" },
    },
    {
      accessorKey: "channel",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Channel" />,
      cell: ({ row }) => <Badge variant="outline">{CHANNEL_LABELS[row.original.channel]}</Badge>,
      meta: { label: "Channel", className: FROM.cost },
    },
    {
      accessorKey: "status",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
      cell: ({ row }) => (
        <StatusBadge status={row.original.status} map={STATUS_MAP} label={STATUS_LABELS[row.original.status]} />
      ),
      meta: { label: "Status", className: FROM.status },
    },
    {
      accessorKey: "spend",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Spend" />,
      cell: ({ row }) => (
        <NumberCell value={row.original.spend} format={(value) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })} />
      ),
      meta: { align: "right", label: "Spend" },
    },
    {
      accessorKey: "clicks",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Clicks" />,
      cell: ({ row }) => <NumberCell value={row.original.clicks} />,
      meta: { align: "right", label: "Clicks", className: FROM.clicks },
    },
    {
      accessorKey: "conversions",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Conversions" />,
      cell: ({ row }) => <NumberCell value={row.original.conversions} />,
      meta: { align: "right", label: "Conversions", className: FROM.conversions },
    },
    {
      id: "cost",
      accessorFn: (row) => (row.conversions === 0 ? undefined : row.spend / row.conversions),
      sortUndefined: "last",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Cost / conv." />,
      cell: ({ row }) => (
        <NumberCell
          value={row.original.conversions === 0 ? null : row.original.spend / row.original.conversions}
          format={(value) => formatCurrency(value)}
        />
      ),
      meta: { align: "right", label: "Cost per conversion", className: FROM.cost },
    },
    {
      id: "rate",
      accessorFn: (row) => (row.clicks === 0 ? undefined : row.conversions / row.clicks),
      sortUndefined: "last",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Conv. rate" />,
      cell: ({ row }) => (
        <NumberCell
          value={row.original.clicks === 0 ? null : row.original.conversions / row.original.clicks}
          format={(value) => formatPercent(value)}
        />
      ),
      meta: { align: "right", label: "Conversion rate", className: FROM.rate },
    },
    {
      id: "actions",
      size: 44,
      enableSorting: false,
      enableHiding: false,
      cell: ({ row }) =>
        row.original.status === "completed" ? null : (
          <DataTableRowActions
            label={`Actions for ${row.original.name}`}
            actions={
              row.original.status === "active"
                ? [{ label: "Pause campaign", icon: <PauseIcon />, onSelect: () => onSwitch(row.original, "paused") }]
                : [{ label: "Resume campaign", icon: <PlayIcon />, onSelect: () => onSwitch(row.original, "active") }]
            }
          />
        ),
    },
  ]
}
app/crm/campaigns/components/campaigns-stats.tsx
"use client"

import { formatCurrency, formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type CampaignRow } from "../data"

/**
 * The slate in four figures, counted from the rows the table holds: what it
 * has spent, what that bought, what each conversion cost, and how many
 * campaigns are running — so pausing one takes it off the live count in the
 * render that turns its pill.
 */
export function CampaignsStats({ rows }: { rows: CampaignRow[] }) {
  const spend = rows.reduce((sum, row) => sum + row.spend, 0)
  const clicks = rows.reduce((sum, row) => sum + row.clicks, 0)
  const conversions = rows.reduce((sum, row) => sum + row.conversions, 0)
  const live = rows.filter((row) => row.status === "active").length
  const paused = rows.filter((row) => row.status === "paused").length
  const ended = rows.filter((row) => row.status === "completed").length

  return (
    <StatCardGroup data-widget="widget-crm-campaigns-campaigns-stats" columns={4}>
      <StatCard
        label="Spend"
        value={formatCurrency(spend, "USD", { maximumFractionDigits: 0 })}
        description={`across ${rows.length} campaigns`}
      />
      <StatCard
        label="Conversions"
        value={formatNumber(conversions)}
        description={`from ${formatNumber(clicks)} clicks`}
      />
      <StatCard
        label="Cost per conversion"
        value={conversions === 0 ? "—" : formatCurrency(spend / conversions)}
        description="spend over conversions"
      />
      <StatCard label="Live campaigns" value={formatNumber(live)} description={`${paused} paused · ${ended} ended`} />
    </StatCardGroup>
  )
}
app/crm/campaigns/components/campaigns-view.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Callout } from "@/components/ui/callout"
import { DataTable, type DataTableFacet } from "@/components/ui/data-table"

import { setCampaignStatus } from "../actions"
import { type CampaignRow, type MonthSpend } from "../data"
import { campaignColumns } from "./campaign-columns"
import { CampaignsStats } from "./campaigns-stats"
import { CHANNEL_LABELS, STATUS_LABELS } from "./campaigns-vocabulary"
import { SpendChart } from "./spend-chart"

const FACETS: DataTableFacet<CampaignRow>[] = [
  {
    columnId: "channel",
    title: "Channel",
    options: Object.entries(CHANNEL_LABELS).map(([value, label]) => ({ label, value })),
  },
  {
    columnId: "status",
    title: "Status",
    options: Object.entries(STATUS_LABELS).map(([value, label]) => ({ label, value })),
  },
]

/**
 * The slate: its four figures, the monthly spend chart and the table, with
 * the figures and the rows held in one island so a switch moves both. Pause and Resume go through the server and the row
 * takes the state the `Result` carries; a success is said in the status line,
 * a refusal — an ended campaign, a campaign gone from the store — in an alert
 * above the table.
 */
export function CampaignsView({ titleId, rows: initial, months }: { titleId: string; rows: CampaignRow[]; months: MonthSpend[] }) {
  const [rows, setRows] = React.useState(initial)
  const [notice, setNotice] = React.useState("")
  const [refusal, setRefusal] = React.useState<string | null>(null)

  const switchTo = React.useCallback(async (row: CampaignRow, status: "active" | "paused") => {
    setNotice("")
    setRefusal(null)
    const result = await setCampaignStatus(row.id, status)
    if (!result.ok) return setRefusal(result.error.message)
    setRows((current) => current.map((entry) => (entry.id === row.id ? { ...entry, status: result.data.status } : entry)))
    setNotice(
      result.data.status === "paused"
        ? `Paused ${result.data.name}. It spends nothing until it is resumed.`
        : `Resumed ${result.data.name}. It is live again.`
    )
  }, [])

  const columns = React.useMemo(() => campaignColumns({ onSwitch: (row, status) => void switchTo(row, status) }), [switchTo])

  return (
    <div className="flex flex-col gap-[var(--density-gap,1rem)]">
      <CampaignsStats rows={rows} />

      <SpendChart months={months} />

      {/* The container the table's columns measure themselves against. */}
      <div className="@container/campaigns flex flex-col gap-3">
        {refusal ? (
          <Callout variant="danger" role="alert" title="Not changed">
            {refusal}
          </Callout>
        ) : null}
        <p
          data-slot="campaigns-status"
          role="status"
          aria-live="polite"
          className={cn("text-sm text-muted-foreground", !notice && "sr-only")}
        >
          {notice}
        </p>

        <DataTable
          aria-labelledby={titleId}
          columns={columns}
          data={rows}
          facets={FACETS}
          searchKey="name"
          searchPlaceholder="Search campaigns"
          getRowId={(row) => row.id}
          enableRowSelection={false}
          emptyMessage="No campaigns match these filters."
          pageSizeOptions={[10, 25]}
        />
      </div>
    </div>
  )
}
app/crm/campaigns/components/campaigns-vocabulary.ts
import { type ChartSeries } from "@/components/ui/chart-core"
import { type StatusVariant } from "@/components/ui/status-badge"

/**
 * The words and colours this page puts on a channel and a status. Vocabulary,
 * not data: it lives beside the islands that print it, because `data.ts`
 * reads `db` and must never reach the browser. The marketing page writes the
 * channels the same way.
 */

export const CHANNEL_LABELS: Record<string, string> = {
  search: "Paid search",
  social: "Paid social",
  email: "Email",
  referral: "Referral",
  organic: "Organic",
}

/**
 * The chart's series, in the order they stack. Paid search, the biggest line
 * of spend, takes the first slot — the grey the palette opens on — and the
 * hues start at the second series, as they do on every chart in the kit.
 */
export const CHANNEL_SERIES: ChartSeries[] = [
  { key: "search", label: "Paid search", color: "chart-1" },
  { key: "social", label: "Paid social", color: "chart-2" },
  { key: "email", label: "Email", color: "chart-3" },
  { key: "referral", label: "Referral", color: "chart-4" },
  { key: "organic", label: "Organic", color: "chart-5" },
]

export const STATUS_LABELS: Record<string, string> = { active: "Active", paused: "Paused", completed: "Completed" }

export const STATUS_MAP: Record<string, StatusVariant> = { active: "success", paused: "warning", completed: "neutral" }
app/crm/campaigns/components/spend-chart.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"

import { type MonthSpend } from "../data"
import { CHANNEL_SERIES } from "./campaigns-vocabulary"

// Fixed to UTC so the axis reads the same wherever the page is rendered.
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const SPAN = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })

const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })

/**
 * Where the money went, month by month: one stack a month, one segment a
 * channel. Each campaign's lifetime spend is spread over the days it ran, so
 * a month holds only what was live in it.
 */
export function SpendChart({ months }: { months: MonthSpend[] }) {
  const first = new Date(`${months[0].month}-01T00:00:00Z`)
  const last = new Date(`${months[months.length - 1].month}-01T00:00:00Z`)

  return (
    <ChartCard
      data-widget="widget-crm-campaigns-spend-chart"
      title="Spend by channel"
      description={`${SPAN.format(first)} to ${SPAN.format(last)}, by month`}
      height={260}
    >
      <StackedBarChart
        data={months}
        index="month"
        series={CHANNEL_SERIES}
        height={260}
        valueFormatter={dollars}
        indexFormatter={(value) => MONTH.format(new Date(`${value}-01T00:00:00Z`))}
      />
    </ChartCard>
  )
}