Skip to contentVibraUI

Campaign figures

What the slate has spent, the conversions it bought from its clicks, what each conversion cost, and how many campaigns are live; reads campaignRows().

Preview

Install

npx shadcn@latest add @vibra/widget-crm-campaigns-campaigns-stats

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

Source

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

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Campaigns page