Skip to contentVibraUI

Marketing headline

Spend, conversions, cost per acquisition and return on ad spend over the window, each against the window before; reads marketingStats().

Preview

Install

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

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

Source

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

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 Marketing dashboard page