Menu

Change language

GitHub repo

Billing Usage Center

blockfinance

Stacked billing center: a current-plan card with seat meter, monthly usage rows whose bars shift success to warning to destructive as they approach the limit (with overage notes), and an invoice history table with status pills, an unpaid-balance alert, and an empty state.

Installation

$npx uipkge-ng@latest add billing-usage-center
Manifest:https://uipkge.dev/r/angular/billing-usage-center.jsonInstalls to:src/app/components/blocks/billing-usage-center/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

PlanInfo
interface PlanInfo {
  name: string
  price: string
  /** Billing cadence shown after the price, e.g. 'month' or 'year'. */
  cadence: string
  renewalDate: string
  seatsUsed: number
  seatsTotal: number
}
UsageMetric
interface UsageMetric {
  id: string
  label: string
  usedText: string
  includedText: string
  /** Percent of the included allowance. Above 100 means overage. */
  percent: number
  /** Shown when percent exceeds 100; falls back to a generic overage note. */
  overageNote?: string
}
Invoice
interface Invoice {
  id: string
  number: string
  period: string
  amount: string
  status: InvoiceStatus
}

Files installed (2)

  • src/app/components/blocks/billing-usage-center/billing-usage-center.component.ts11.1 kB
    // Stacked billing center: a current-plan card with seat meter, monthly usage rows whose
    // bars shift success → warning → destructive as they approach the limit (with overage
    // notes), and an invoice history table with status pills, an unpaid-balance alert, and an
    // empty state. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import { CreditCard, Download, FileText, LucideAngularModule, TriangleAlert } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAlertDescriptionComponent, UiAlertTitleComponent, UiAlertComponent } from '@/ui/alert/alert.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSectionCardComponent } from '@/ui/section-card/section-card.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export interface PlanInfo {
      name: string
      price: string
      /** Billing cadence shown after the price, e.g. 'month' or 'year'. */
      cadence: string
      renewalDate: string
      seatsUsed: number
      seatsTotal: number
    }
    
    export interface UsageMetric {
      id: string
      label: string
      usedText: string
      includedText: string
      /** Percent of the included allowance. Above 100 means overage. */
      percent: number
      /** Shown when percent exceeds 100; falls back to a generic overage note. */
      overageNote?: string
    }
    
    export type InvoiceStatus = 'paid' | 'open' | 'past-due'
    
    export interface Invoice {
      id: string
      number: string
      period: string
      amount: string
      status: InvoiceStatus
    }
    
    const defaultPlan: PlanInfo = {
      name: 'Pro',
      price: '$49',
      cadence: 'month',
      renewalDate: 'September 12, 2026',
      seatsUsed: 18,
      seatsTotal: 20,
    }
    
    const defaultUsage: UsageMetric[] = [
      { id: 'storage', label: 'Storage', usedText: '8.2 GB', includedText: '10 GB', percent: 82 },
      { id: 'bandwidth', label: 'Bandwidth', usedText: '412 GB', includedText: '1 TB', percent: 41 },
      { id: 'api-calls', label: 'API calls', usedText: '640K', includedText: '1M', percent: 64 },
    ]
    
    const defaultInvoices: Invoice[] = [
      { id: 'inv-003', number: 'INV-2026-003', period: 'Jul 1 – Jul 31, 2026', amount: '$49.00', status: 'paid' },
      { id: 'inv-002', number: 'INV-2026-002', period: 'Jun 1 – Jun 30, 2026', amount: '$49.00', status: 'paid' },
      { id: 'inv-001', number: 'INV-2026-001', period: 'May 1 – May 31, 2026', amount: '$49.00', status: 'paid' },
    ]
    
    // Inlined record (not an indexed VariantProps access): keeps the compiler happy.
    const statusMeta: Record<InvoiceStatus, { label: string; variant: 'success' | 'default' | 'destructive' }> = {
      paid: { label: 'Paid', variant: 'success' },
      open: { label: 'Open', variant: 'default' },
      'past-due': { label: 'Past due', variant: 'destructive' },
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-billing-usage-center, [ui-billing-usage-center]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiAlertComponent,
        UiAlertDescriptionComponent,
        UiAlertTitleComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiProgressComponent,
        UiSectionCardComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="billing-usage-center" [class]="rootClass">
          <div ui-section-card title="Current plan" [description]="'Renews on ' + plan().renewalDate">
            <div class="flex items-start justify-between gap-4">
              <div>
                <p class="text-2xl font-bold tracking-tight">{{ plan().name }}</p>
                <p class="text-muted-foreground mt-1 text-sm">
                  {{ plan().price }}
                  <span class="text-xs">/ {{ plan().cadence }}</span>
                </p>
              </div>
            </div>
    
            <div class="mt-6 space-y-1.5">
              <div class="flex flex-wrap items-baseline justify-between gap-4 text-sm">
                <span class="font-medium">Seats used</span>
                <span class="text-muted-foreground text-xs tabular-nums"
                  >{{ plan().seatsUsed }} of {{ plan().seatsTotal }}</span
                >
              </div>
              <ui-progress [value]="seatsPercent()" />
              <p class="text-muted-foreground text-xs">{{ seatsRemaining() }} seats remaining on this plan.</p>
            </div>
    
            <div class="mt-6 flex flex-wrap justify-end gap-2">
              <button ui-button variant="outline" size="sm">
                <lucide-icon [img]="CreditCard" />
                Manage payment
              </button>
              <button ui-button size="sm">Change plan</button>
            </div>
          </div>
    
          <div ui-section-card title="Usage this month" description="Metered against your plan allowances.">
            <div class="space-y-5">
              @for (metric of usage(); track metric.id) {
                <div class="space-y-1.5">
                  <div class="flex flex-wrap items-baseline justify-between gap-4 text-sm">
                    <span class="font-medium">{{ metric.label }}</span>
                    <span class="text-muted-foreground text-xs tabular-nums">
                      {{ metric.usedText }} of {{ metric.includedText }}
                    </span>
                  </div>
                  <ui-progress [value]="Math.min(metric.percent, 100)" [class]="usageBarClass(metric.percent)" />
                  @if (metric.percent > 100) {
                    <p class="text-destructive text-xs">
                      {{
                        metric.overageNote ??
                          'Over the ' + metric.includedText + ' included in your plan — overage charges apply.'
                      }}
                    </p>
                  }
                </div>
              }
            </div>
          </div>
    
          <div
            ui-section-card
            title="Invoice history"
            [description]="invoices().length + ' invoice' + (invoices().length === 1 ? '' : 's')"
          >
            @if (invoices().length > 0) {
              <div class="space-y-4">
                @if (unpaidInvoices().length > 0) {
                  <div ui-alert class="border-warning/40 bg-warning/5">
                    <lucide-icon [img]="TriangleAlert" class="text-warning size-4" />
                    <h5 ui-alert-title>Past-due balance</h5>
                    <p ui-alert-description>{{ unpaidNote() }}</p>
                  </div>
                }
    
                <div class="overflow-x-auto">
                  <table ui-table>
                    <thead ui-table-header>
                      <tr ui-table-row>
                        <th ui-table-head>Invoice</th>
                        <th ui-table-head>Period</th>
                        <th ui-table-head class="text-right">Amount</th>
                        <th ui-table-head>Status</th>
                        <th ui-table-head class="w-12">
                          <span class="sr-only">Download</span>
                        </th>
                      </tr>
                    </thead>
                    <tbody ui-table-body>
                      @for (invoice of invoices(); track invoice.id) {
                        <tr ui-table-row>
                          <td ui-table-cell class="font-medium">{{ invoice.number }}</td>
                          <td ui-table-cell class="text-muted-foreground">{{ invoice.period }}</td>
                          <td ui-table-cell class="text-right tabular-nums">{{ invoice.amount }}</td>
                          <td ui-table-cell>
                            <span ui-badge [variant]="invoiceVariant(invoice.status)">{{
                              invoiceLabel(invoice.status)
                            }}</span>
                          </td>
                          <td ui-table-cell class="text-right">
                            <button
                              ui-button
                              variant="ghost"
                              size="icon-sm"
                              [attr.aria-label]="'Download invoice ' + invoice.number"
                            >
                              <lucide-icon [img]="Download" />
                            </button>
                          </td>
                        </tr>
                      }
                    </tbody>
                  </table>
                </div>
              </div>
            } @else {
              <div class="flex flex-col items-center justify-center px-6 py-10 text-center">
                <div class="bg-muted mb-3 rounded-full p-3">
                  <lucide-icon [img]="FileText" class="text-muted-foreground size-5" />
                </div>
                <p class="text-sm font-medium">No invoices yet</p>
                <p class="text-muted-foreground mt-0.5 text-xs">Invoices appear here after your first billing cycle.</p>
              </div>
            }
          </div>
        </div>
      `,
    })
    export class UiBillingUsageCenterComponent {
      protected readonly CreditCard = CreditCard
      protected readonly Download = Download
      protected readonly FileText = FileText
      protected readonly Math = Math
      protected readonly TriangleAlert = TriangleAlert
    
      readonly plan = signal<PlanInfo>({ ...defaultPlan })
      readonly usage = signal<UsageMetric[]>(defaultUsage.map((m) => ({ ...m })))
      readonly invoices = signal<Invoice[]>(defaultInvoices.map((i) => ({ ...i })))
    
      @Input('plan') set planValue(v: PlanInfo | undefined) {
        if (v) this.plan.set({ ...v })
      }
      @Input('usage') set usageValue(v: UsageMetric[] | undefined) {
        if (v) this.usage.set(v.map((m) => ({ ...m })))
      }
      @Input('invoices') set invoicesValue(v: Invoice[] | undefined) {
        if (v) this.invoices.set(v.map((i) => ({ ...i })))
      }
      @Input('class') className?: string
    
      readonly seatsPercent = computed(() => {
        const p = this.plan()
        return p.seatsTotal > 0 ? (p.seatsUsed / p.seatsTotal) * 100 : 0
      })
    
      readonly seatsRemaining = computed(() => Math.max(this.plan().seatsTotal - this.plan().seatsUsed, 0))
      readonly unpaidInvoices = computed(() => this.invoices().filter((invoice) => invoice.status !== 'paid'))
      readonly pastDueCount = computed(() => this.invoices().filter((invoice) => invoice.status === 'past-due').length)
    
      readonly unpaidNote = computed(() => {
        const count = this.unpaidInvoices().length
        const past = this.pastDueCount()
        const plural = count === 1 ? '' : 's'
        const pastPart = past > 0 ? `, ${past} of them past due` : ''
        return `You have ${count} unpaid invoice${plural}${pastPart}. Update your payment method to avoid service interruption.`
      })
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-3xl space-y-4', this.className)
      }
    
      invoiceLabel(status: InvoiceStatus): string {
        return statusMeta[status].label
      }
    
      invoiceVariant(status: InvoiceStatus): 'success' | 'default' | 'destructive' {
        return statusMeta[status].variant
      }
    
      // success below 80%, warning as usage approaches the limit, destructive at/over it.
      usageBarClass(percent: number): string {
        if (percent >= 100) return '[&_[data-slot=progress-indicator]]:bg-destructive'
        if (percent >= 80) return '[&_[data-slot=progress-indicator]]:bg-warning'
        return '[&_[data-slot=progress-indicator]]:bg-success'
      }
    }
    
  • src/app/components/blocks/billing-usage-center/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/billing-usage-center.json