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

Marketing dashboard

A campaign dashboard: spend, conversions, cost per acquisition and return on ad spend, daily spend against conversions, the channel mix, the biggest campaigns, and the UTM links behind them.

Open the live page

The page is a server component inside AppShell: it reads db.campaigns on the server and only the two charts cross into the client, because a chart formats its own axis labels. Campaigns, their spend, clicks and conversions are db.campaigns rows; what a conversion is worth is the mean MRR of the paying accounts in db.customers, so cost per acquisition and return on ad spend are the data's own numbers. The daily series is derived rather than seeded — each campaign's totals are spread across the days it was actually live, shaped by a weekday rule and rescaled so the window still totals what the campaigns spent. The UTM rows are the same campaign rows read as tagged links. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, ChartCard, ComposedChart, DonutChart, Widget, SimpleTable and StatusBadge.

Preview

Install

npx shadcn@latest add @vibra/crm-marketing

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

Source

app/crm/marketing/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { CampaignsTable } from "./components/campaigns-table"
import { ChannelMix } from "./components/channel-mix"
import { MarketingStats } from "./components/marketing-stats"
import { SpendConversionsChart } from "./components/spend-conversions-chart"
import { UtmTable } from "./components/utm-table"
import {
  channelMix,
  currentUser,
  shellNotifications,
  spendAndConversions,
  windowLabel,
  WINDOW_DAYS,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/crm/nav"

/**
 * The marketing page. It is a server component: every number is read from db
 * on the server, and only the two charts cross into the client, because a
 * chart formats its own axis labels. They are handed their points; neither
 * imports data.ts, so the store stays on the server.
 */
export default function MarketingPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.marketing}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Marketing"
        description={`What the last ${WINDOW_DAYS} days of campaigns bought, and what each one cost.`}
        meta={windowLabel()}
      />

      <MarketingStats />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
          <SpendConversionsChart points={spendAndConversions()} window={windowLabel()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <ChannelMix slices={channelMix()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          <CampaignsTable />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          <UtmTable />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/crm/marketing/data.ts
/**
 * What this page reads. Every campaign, its spend and its conversions come
 * from `db.campaigns`, and a conversion is valued at the whole paying book in
 * `db.customers` divided by every conversion that ever bought it — so CAC and
 * ROAS are the data's own numbers, not invented ones.
 *
 * The daily series behind the chart has no entity of its own. It is derived,
 * not seeded: a campaign's spend and conversions are spread across the days it
 * was actually live, then shaped by a weekday rule (a weekend runs at 0.55 of
 * a weekday) and rescaled so the window still totals what the campaigns spent.
 * "Now" is `REFERENCE_DATE`; nothing here reads a clock.
 */
import { formatCurrency, formatNumber, getInitials } from "@/lib/format"
import {
  db,
  REFERENCE_DATE,
  slugify,
  type Campaign,
  type Member,
} from "@/lib/sample-data"

export type { Campaign }

const DAY_MS = 86_400_000

/** The window every headline number and the chart are measured over. */
export const WINDOW_DAYS = 30

/** Midnight UTC on the last complete day before "now". */
const LAST_DAY =
  Date.UTC(
    REFERENCE_DATE.getUTCFullYear(),
    REFERENCE_DATE.getUTCMonth(),
    REFERENCE_DATE.getUTCDate()
  ) - DAY_MS

// A campaign with no end date is still running, so it counts up to today.
const endOf = (campaign: Campaign): number => (campaign.endedAt ?? REFERENCE_DATE).getTime()

// Marketing runs on the working week: a weekend day carries a little over half
// a weekday's spend. The series is rescaled afterwards, so this only moves
// budget between days — it never invents any.
const WEEKEND_FACTOR = 0.55

export type DayPoint = { date: string; spend: number; conversions: number }

/** The `WINDOW_DAYS` days ending `offset` windows back, oldest first. */
function windowDays(offset: number): number[] {
  const end = LAST_DAY - offset * WINDOW_DAYS * DAY_MS
  return Array.from({ length: WINDOW_DAYS }, (_, index) => end - (WINDOW_DAYS - 1 - index) * DAY_MS)
}

/**
 * A window's daily spend and conversions: each campaign's totals divided by
 * the days it ran, counted only on the days inside the window.
 */
function dailySeries(offset = 0): DayPoint[] {
  const days = windowDays(offset)
  const campaigns = db.campaigns.all()

  const raw = days.map((day) => {
    let spend = 0
    let conversions = 0
    for (const campaign of campaigns) {
      const started = campaign.startedAt.getTime()
      const ended = endOf(campaign)
      if (day < started || day > ended) continue
      const liveDays = Math.max(1, Math.round((ended - started) / DAY_MS))
      spend += campaign.spendCents / 100 / liveDays
      conversions += campaign.conversions / liveDays
    }
    return { spend, conversions }
  })

  const shaped = days.map((day, index) => {
    const weekday = new Date(day).getUTCDay()
    const factor = weekday === 0 || weekday === 6 ? WEEKEND_FACTOR : 1
    return { spend: raw[index].spend * factor, conversions: raw[index].conversions * factor }
  })

  const total = (rows: { spend: number; conversions: number }[], key: "spend" | "conversions") =>
    rows.reduce((sum, row) => sum + row[key], 0)

  const spendScale = total(shaped, "spend") > 0 ? total(raw, "spend") / total(shaped, "spend") : 1
  const conversionScale =
    total(shaped, "conversions") > 0 ? total(raw, "conversions") / total(shaped, "conversions") : 1

  return days.map((day, index) => ({
    date: new Date(day).toISOString().slice(0, 10),
    spend: Math.round(shaped[index].spend * spendScale),
    conversions: Math.round(shaped[index].conversions * conversionScale),
  }))
}

/** The daily spend and conversions inside the window, oldest first — read per call, like every row here. */
export function spendAndConversions(): DayPoint[] {
  return dailySeries(0)
}

// What one conversion is worth, blended: the whole paying book in
// `db.customers` divided by every conversion in `db.campaigns` that ever
// bought it. Most conversions are a trial that never bills, which is why this
// lands two orders of magnitude below the mean MRR of an account that does.
function conversionValue(): number {
  const bookCents = db.customers.all().reduce((sum, customer) => sum + customer.mrrCents, 0)
  const conversions = db.campaigns.all().reduce((sum, campaign) => sum + campaign.conversions, 0)
  return conversions === 0 ? 0 : bookCents / 100 / conversions
}

export type MarketingStat = {
  key: string
  label: string
  value: string
  delta: number
  description: string
  /** False where down is the win — cost per acquisition. */
  positiveIsGood: boolean
}

const sum = (rows: DayPoint[], key: "spend" | "conversions"): number =>
  rows.reduce((total, row) => total + row[key], 0)

const ratio = (current: number, previous: number): number =>
  previous === 0 ? 0 : current / previous - 1

/** Spend, conversions, cost per acquisition and return on ad spend, each against the previous window. */
export function marketingStats(): MarketingStat[] {
  const thisWindow = dailySeries(0)
  const lastWindow = dailySeries(1)
  const value = conversionValue()
  const spend = sum(thisWindow, "spend")
  const conversions = sum(thisWindow, "conversions")
  const wasSpend = sum(lastWindow, "spend")
  const wasConversions = sum(lastWindow, "conversions")

  const cac = conversions === 0 ? 0 : spend / conversions
  const wasCac = wasConversions === 0 ? 0 : wasSpend / wasConversions
  const roas = spend === 0 ? 0 : (conversions * value) / spend
  const wasRoas = wasSpend === 0 ? 0 : (wasConversions * value) / wasSpend
  const against = `vs previous ${WINDOW_DAYS} days`

  return [
    {
      key: "spend",
      label: "Spend",
      value: formatCurrency(spend, "USD", { maximumFractionDigits: 0 }),
      delta: ratio(spend, wasSpend),
      description: against,
      positiveIsGood: true,
    },
    {
      key: "conversions",
      label: "Conversions",
      value: formatNumber(conversions, { maximumFractionDigits: 0 }),
      delta: ratio(conversions, wasConversions),
      description: against,
      positiveIsGood: true,
    },
    {
      key: "cac",
      label: "Cost per acquisition",
      value: formatCurrency(cac, "USD", { maximumFractionDigits: 2 }),
      delta: ratio(cac, wasCac),
      description: against,
      positiveIsGood: false,
    },
    {
      key: "roas",
      label: "Return on ad spend",
      value: `${formatNumber(roas, { maximumFractionDigits: 2 })}x`,
      delta: ratio(roas, wasRoas),
      description: `${formatCurrency(value, "USD", { maximumFractionDigits: 2 })} a conversion`,
      positiveIsGood: true,
    },
  ]
}

// How a channel is written for a reader, and the order the mix is drawn in.
const CHANNEL_LABELS: Record<Campaign["channel"], string> = {
  search: "Paid search",
  social: "Paid social",
  email: "Email",
  referral: "Referral",
  organic: "Organic",
}

/** How a campaign's channel is written on this page. */
export function channelLabel(channel: Campaign["channel"]): string {
  return CHANNEL_LABELS[channel]
}

/** Lifetime spend by channel, largest first. */
export function channelMix(): { name: string; value: number }[] {
  const totals = new Map<Campaign["channel"], number>()
  for (const campaign of db.campaigns.all()) {
    totals.set(campaign.channel, (totals.get(campaign.channel) ?? 0) + campaign.spendCents / 100)
  }
  return [...totals]
    .map(([channel, value]) => ({ name: CHANNEL_LABELS[channel], value: Math.round(value) }))
    .sort((a, b) => b.value - a.value)
}

export type CampaignRow = Campaign & {
  /** Spend divided by conversions; null when the campaign has converted nobody. */
  cpaCents: number | null
  /** Conversions divided by clicks. */
  conversionRate: number
}

const withRates = (campaign: Campaign): CampaignRow => ({
  ...campaign,
  cpaCents: campaign.conversions === 0 ? null : campaign.spendCents / campaign.conversions,
  conversionRate: campaign.clicks === 0 ? 0 : campaign.conversions / campaign.clicks,
})

/** The campaigns that spent the most, biggest budget first. */
export function topCampaigns(limit = 8): CampaignRow[] {
  return db.campaigns
    .all()
    .sort((a, b) => b.spendCents - a.spendCents)
    .slice(0, limit)
    .map(withRates)
}

// The source and medium a channel is tagged with in a link — the vocabulary
// an analytics tool expects, not a naming scheme of our own. The source is the
// kind of place the link ran, never the company that ran it.
const UTM_TAGS: Record<Campaign["channel"], readonly [string, string]> = {
  search: ["search", "cpc"],
  social: ["social", "paid_social"],
  email: ["newsletter", "email"],
  referral: ["partners", "referral"],
  organic: ["search", "organic"],
}

export type UtmRow = {
  id: string
  source: string
  medium: string
  campaign: string
  sessions: number
  conversions: number
  conversionRate: number
}

/**
 * The tagged links behind the campaigns, busiest first. Source, medium and
 * campaign are the campaign row's own channel and name; sessions are its
 * clicks.
 */
export function utmRows(limit = 8): UtmRow[] {
  return db.campaigns
    .all()
    .sort((a, b) => b.clicks - a.clicks)
    .slice(0, limit)
    .map((campaign) => ({
      id: campaign.id,
      source: UTM_TAGS[campaign.channel][0],
      medium: UTM_TAGS[campaign.channel][1],
      campaign: slugify(campaign.name),
      sessions: campaign.clicks,
      conversions: campaign.conversions,
      conversionRate: campaign.clicks === 0 ? 0 : campaign.conversions / campaign.clicks,
    }))
}

/** 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 }))
}

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 }
}

// Fixed to UTC so the line reads the same wherever the page is rendered.
const WINDOW_LABEL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })

/** The window the page covers, written out. */
export function windowLabel(): string {
  const first = new Date(LAST_DAY - (WINDOW_DAYS - 1) * DAY_MS)
  const last = new Date(LAST_DAY)
  return `${WINDOW_LABEL.format(first)} – ${WINDOW_LABEL.format(last)} UTC`
}
app/crm/marketing/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/crm/marketing/components/campaigns-table.tsx
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"

import { channelLabel, topCampaigns, type CampaignRow } from "../data"

const money = (cents: number) =>
  formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })

const COLUMNS: SimpleTableColumn<CampaignRow>[] = [
  {
    key: "name",
    header: "Campaign",
    cell: (row) => (
      <div className="flex min-w-0 flex-col">
        <span className="truncate font-medium">{row.name}</span>
        <span className="text-xs text-muted-foreground">{channelLabel(row.channel)}</span>
      </div>
    ),
  },
  {
    key: "status",
    header: "Status",
    cell: (row) => (
      <StatusBadge
        status={row.status}
        map={{ completed: "neutral", paused: "warning", active: "success" }}
      />
    ),
  },
  { key: "spend", header: "Spend", align: "right", cell: (row) => money(row.spendCents) },
  {
    key: "clicks",
    header: "Clicks",
    align: "right",
    cell: (row) => formatNumber(row.clicks, { maximumFractionDigits: 0 }),
  },
  {
    key: "conversions",
    header: "Conversions",
    align: "right",
    cell: (row) => formatNumber(row.conversions, { maximumFractionDigits: 0 }),
  },
  {
    key: "conversionRate",
    header: "Rate",
    align: "right",
    cell: (row) => formatPercent(row.conversionRate, { maximumFractionDigits: 2 }),
  },
  {
    key: "cpa",
    header: "CPA",
    align: "right",
    cell: (row) =>
      row.cpaCents === null ? (
        <span className="text-muted-foreground">—</span>
      ) : (
        money(row.cpaCents)
      ),
  },
]

export function CampaignsTable() {
  const rows = topCampaigns()

  return (
    <Widget
      data-widget="widget-crm-marketing-campaigns-table"
      title="Campaigns"
      description="The eight biggest budgets, whatever channel they run on"
      className="h-full"
      contentClassName="px-0"
      footer="A campaign with no conversions yet has no cost per acquisition to report."
    >
      <SimpleTable
        columns={COLUMNS}
        rows={rows}
        rowKey="id"
        size="sm"
        caption="Campaigns by spend"
      />
    </Widget>
  )
}
app/crm/marketing/components/channel-mix.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { DonutChart } from "@/components/ui/donut-chart"

/** Lifetime spend split by channel, largest share first; the slices arrive from the server. */
export function ChannelMix({ slices }: { slices: { name: string; value: number }[] }) {
  const total = slices.reduce((sum, slice) => sum + slice.value, 0)
  const lead = slices[0]

  return (
    <ChartCard
      data-widget="widget-crm-marketing-channel-mix"
      title="Channel mix"
      description="Lifetime spend by channel"
      height={300}
      className="h-full"
      footer={`${lead.name} takes ${Math.round((lead.value / total) * 100)}% of everything spent so far.`}
    >
      <DonutChart
        data={slices}
        height={300}
        centerLabel="Total spend"
        centerValue={formatCurrency(total, "USD", { compact: true })}
        valueFormatter={(value) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
      />
    </ChartCard>
  )
}
app/crm/marketing/components/marketing-stats.tsx
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { marketingStats } from "../data"

export function MarketingStats() {
  return (
    <StatCardGroup data-widget="widget-crm-marketing-marketing-stats" columns={4}>
      {marketingStats().map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={stat.value}
          delta={stat.delta}
          positiveIsGood={stat.positiveIsGood}
          description={stat.description}
        />
      ))}
    </StatCardGroup>
  )
}
app/crm/marketing/components/spend-conversions-chart.tsx
"use client"

import { formatCurrency, formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { ComposedChart } from "@/components/ui/composed-chart"

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

// Spend is money and conversions are people, so they never share a scale:
// spend is the column, conversions the line on the right-hand axis.
const BARS = [{ key: "spend", label: "Spend", color: "chart-1" as const }]
const LINES = [{ key: "conversions", label: "Conversions", color: "chart-4" as const }]
const RIGHT_AXIS = ["conversions"]

const DAY_LABEL = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})

export type SpendConversionsChartProps = {
  /** One point a day across the window, oldest first. */
  points: DayPoint[]
  /** The window written out, e.g. "Aug 25, 2026 – Sep 23, 2026 UTC". */
  window: string
}

/** What the window spent, day by day, against the conversions it bought. */
export function SpendConversionsChart({ points, window }: SpendConversionsChartProps) {
  const spend = points.reduce((total, point) => total + point.spend, 0)
  const conversions = points.reduce((total, point) => total + point.conversions, 0)

  return (
    <ChartCard
      data-widget="widget-crm-marketing-spend-conversions-chart"
      title="Spend and conversions"
      description={`Every campaign that was live, day by day — ${window}`}
      height={360}
      className="h-full"
      footer={`${formatCurrency(spend, "USD", { maximumFractionDigits: 0 })} bought ${formatNumber(conversions, { maximumFractionDigits: 0 })} conversions over ${points.length} days.`}
    >
      <ComposedChart
        data={points}
        index="date"
        series={[...BARS, ...LINES]}
        bars={BARS}
        lines={LINES}
        rightAxisKeys={RIGHT_AXIS}
        height={360}
        showYAxis
        indexFormatter={(value) => DAY_LABEL.format(new Date(`${value}T00:00:00Z`))}
        valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
      />
    </ChartCard>
  )
}
app/crm/marketing/components/utm-table.tsx
import { formatNumber, formatPercent } from "@/lib/format"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { Widget } from "@/components/ui/widget"

import { utmRows, type UtmRow } from "../data"

const mono = (value: string) => <span className="font-mono text-xs">{value}</span>

const COLUMNS: SimpleTableColumn<UtmRow>[] = [
  { key: "source", header: "utm_source", cell: (row) => mono(row.source) },
  { key: "medium", header: "utm_medium", cell: (row) => mono(row.medium) },
  {
    key: "campaign",
    header: "utm_campaign",
    cell: (row) => <span className="font-mono text-xs">{row.campaign}</span>,
  },
  {
    key: "sessions",
    header: "Sessions",
    align: "right",
    cell: (row) => formatNumber(row.sessions, { maximumFractionDigits: 0 }),
  },
  {
    key: "conversions",
    header: "Conversions",
    align: "right",
    cell: (row) => formatNumber(row.conversions, { maximumFractionDigits: 0 }),
  },
  {
    key: "conversionRate",
    header: "Rate",
    align: "right",
    cell: (row) => formatPercent(row.conversionRate, { maximumFractionDigits: 2 }),
  },
]

export function UtmTable() {
  return (
    <Widget
      data-widget="widget-crm-marketing-utm-table"
      title="UTM sources"
      description="The tagged links behind the campaigns, busiest first"
      className="h-full"
      contentClassName="px-0"
      footer="Every campaign link carries the same three tags, so a session can be traced back to the push that bought it."
    >
      <SimpleTable
        columns={COLUMNS}
        rows={utmRows()}
        rowKey="id"
        size="sm"
        caption="Tagged links by sessions"
      />
    </Widget>
  )
}