Menu

Change language

GitHub repo

Pricing Calculator

blockmarketing

Interactive SaaS and API pricing calculator with dynamic seat, request throughput, and cloud storage sliders, annual billing toggle with 20% discount, enterprise add-on switches, automated tier recommendation, and sticky live estimated cost breakdown.

Installation

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

Variants

Loading interactive previews…

Schema

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

PricingCalculatorRequestTier
interface PricingCalculatorRequestTier {
  label: string
  requests: number
  cost: number
}
PricingCalculatorStorageTier
interface PricingCalculatorStorageTier {
  label: string
  gb: number
  cost: number
}

Used by

Files installed (2)

  • src/app/components/blocks/pricing-calculator/pricing-calculator.component.ts23.5 kB
    // Interactive SaaS and API pricing calculator: seat, request throughput, and cloud storage
    // sliders, annual billing toggle with 20% discount, enterprise add-on switches, automated tier
    // recommendation, and a sticky live estimated cost breakdown. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      ArrowRight,
      Check,
      HardDrive,
      Headphones,
      KeyRound,
      LucideAngularModule,
      ShieldCheck,
      Users,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    
    export interface PricingCalculatorRequestTier {
      label: string
      requests: number
      cost: number
    }
    
    export interface PricingCalculatorStorageTier {
      label: string
      gb: number
      cost: number
    }
    
    const REQUEST_TIERS: PricingCalculatorRequestTier[] = [
      { label: '10K requests', requests: 10_000, cost: 0 },
      { label: '50K requests', requests: 50_000, cost: 25 },
      { label: '100K requests', requests: 100_000, cost: 60 },
      { label: '250K requests', requests: 250_000, cost: 120 },
      { label: '500K requests', requests: 500_000, cost: 220 },
      { label: '1M requests', requests: 1_000_000, cost: 400 },
      { label: '2.5M requests', requests: 2_500_000, cost: 750 },
      { label: '5M requests', requests: 5_000_000, cost: 1200 },
    ]
    
    const STORAGE_TIERS: PricingCalculatorStorageTier[] = [
      { label: '100 GB', gb: 100, cost: 10 },
      { label: '250 GB', gb: 250, cost: 25 },
      { label: '500 GB', gb: 500, cost: 45 },
      { label: '1 TB', gb: 1_000, cost: 80 },
      { label: '2 TB', gb: 2_000, cost: 150 },
      { label: '5 TB', gb: 5_000, cost: 320 },
      { label: '10 TB', gb: 10_000, cost: 580 },
    ]
    
    const SEAT_PRICE = 15
    const SUPPORT_MANAGER_PRICE = 200
    const CUSTOM_SLA_PRICE = 500
    const SSO_PRICE = 100
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-pricing-calculator, [ui-pricing-calculator]',
      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,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiSeparatorComponent,
        UiSliderComponent,
        UiSwitchComponent,
      ],
      template: `
        <div data-slot="pricing-calculator" [class]="rootClass">
          <div class="flex flex-col items-center gap-4 text-center">
            <span ui-badge variant="outline" class="gap-1.5 px-3 py-1 text-xs font-medium tracking-wider uppercase">
              <lucide-icon [img]="ShieldCheck" class="text-primary size-3.5" />
              Pricing Calculator
            </span>
            <div class="space-y-2">
              <h2 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
                Interactive Pricing Calculator
              </h2>
              <p class="text-muted-foreground mx-auto max-w-2xl text-sm sm:text-base">
                Calculate your exact monthly or annual investment based on usage.
              </p>
            </div>
    
            <div class="border-border bg-card mt-2 inline-flex items-center gap-3 rounded-full border px-4 py-2 shadow-xs">
              <span [class]="billingLabelClass(!isAnnual())">Monthly</span>
              <button
                ui-switch
                aria-label="Toggle annual billing"
                [checked]="isAnnual()"
                (checkedChange)="isAnnual.set($event)"
              ></button>
              <span [class]="billingLabelClass(isAnnual())">Pay Annually</span>
              <span ui-badge variant="secondary" class="bg-primary/10 text-primary border-primary/20 text-xs font-semibold">
                Save 20%
              </span>
            </div>
          </div>
    
          <div class="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:items-start">
            <div class="space-y-6 lg:col-span-7">
              <div ui-card>
                <div ui-card-header>
                  <h3 ui-card-title>Capacity &amp; Scale</h3>
                  <p ui-card-description>Configure team seats, API request throughput, and dedicated storage.</p>
                </div>
                <div ui-card-content class="space-y-8">
                  <div class="space-y-4">
                    <div class="flex items-center justify-between">
                      <div class="flex items-center gap-2.5">
                        <div
                          class="bg-primary/10 text-primary border-primary/20 flex size-8 shrink-0 items-center justify-center rounded-md border"
                        >
                          <lucide-icon [img]="Users" class="size-4" />
                        </div>
                        <div>
                          <span class="text-foreground text-sm font-medium">Team Seats</span>
                          <span ui-badge [variant]="seatTier().variant" class="ml-2 text-xs font-normal">
                            {{ seatTier().label }}
                          </span>
                        </div>
                      </div>
                      <div class="text-right">
                        <span class="text-foreground text-base font-semibold tabular-nums">{{ seats() }}</span>
                        <span class="text-muted-foreground text-xs"> seats (&#36;{{ seats() * seatPrice }}/mo)</span>
                      </div>
                    </div>
                    <div class="pb-6">
                      <ui-slider
                        [value]="[seats()]"
                        [min]="1"
                        [max]="100"
                        [step]="1"
                        [marks]="seatMarks"
                        (valueChange)="onSeatsChange($event)"
                      />
                    </div>
                    <p class="text-muted-foreground text-xs">
                      Full workspace access, fine-grained permission controls, and audit log tracking for each member.
                    </p>
                  </div>
    
                  <div ui-separator></div>
    
                  <div class="space-y-4">
                    <div class="flex items-center justify-between">
                      <div class="flex items-center gap-2.5">
                        <div
                          class="bg-primary/10 text-primary border-primary/20 flex size-8 shrink-0 items-center justify-center rounded-md border"
                        >
                          <lucide-icon [img]="Zap" class="size-4" />
                        </div>
                        <div>
                          <span class="text-foreground text-sm font-medium">Monthly API Requests</span>
                        </div>
                      </div>
                      <div class="text-right">
                        <span class="text-foreground text-base font-semibold tabular-nums">
                          {{ currentRequestTier().label }}
                        </span>
                        <span class="text-muted-foreground text-xs">
                          ({{
                            currentRequestTier().cost === 0 ? 'Included' : '+' + '$' + currentRequestTier().cost + '/mo'
                          }})
                        </span>
                      </div>
                    </div>
                    <div class="pb-6">
                      <ui-slider
                        [value]="[requestIndex()]"
                        [min]="0"
                        [max]="7"
                        [step]="1"
                        [marks]="requestMarks"
                        (valueChange)="onRequestChange($event)"
                      />
                    </div>
                    <p class="text-muted-foreground text-xs">
                      Globally distributed edge endpoints, automatic rate limiting, and sub-50ms p99 latency SLA.
                    </p>
                  </div>
    
                  <div ui-separator></div>
    
                  <div class="space-y-4">
                    <div class="flex items-center justify-between">
                      <div class="flex items-center gap-2.5">
                        <div
                          class="bg-primary/10 text-primary border-primary/20 flex size-8 shrink-0 items-center justify-center rounded-md border"
                        >
                          <lucide-icon [img]="HardDrive" class="size-4" />
                        </div>
                        <div>
                          <span class="text-foreground text-sm font-medium">Dedicated Cloud Storage</span>
                        </div>
                      </div>
                      <div class="text-right">
                        <span class="text-foreground text-base font-semibold tabular-nums">
                          {{ currentStorageTier().label }}
                        </span>
                        <span class="text-muted-foreground text-xs"> (+&#36;{{ currentStorageTier().cost }}/mo)</span>
                      </div>
                    </div>
                    <div class="pb-6">
                      <ui-slider
                        [value]="[storageIndex()]"
                        [min]="0"
                        [max]="6"
                        [step]="1"
                        [marks]="storageMarks"
                        (valueChange)="onStorageChange($event)"
                      />
                    </div>
                    <p class="text-muted-foreground text-xs">
                      Encrypted at rest (AES-256) with multi-region automated replication and daily disaster backups.
                    </p>
                  </div>
                </div>
              </div>
    
              <div ui-card>
                <div ui-card-header>
                  <h3 ui-card-title>Enterprise Add-ons</h3>
                  <p ui-card-description>
                    Enhance your infrastructure with enterprise compliance, reliability, and support.
                  </p>
                </div>
                <div ui-card-content class="space-y-3">
                  <div
                    class="border-border bg-card hover:border-primary/30 flex flex-col justify-between gap-4 rounded-lg border p-4 transition-colors sm:flex-row sm:items-center"
                  >
                    <div class="flex items-start gap-3">
                      <div
                        class="bg-primary/10 text-primary border-primary/20 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border"
                      >
                        <lucide-icon [img]="Headphones" class="size-4" />
                      </div>
                      <div class="space-y-0.5">
                        <div class="flex items-center gap-2">
                          <span class="text-foreground text-sm font-medium">Dedicated Support Manager</span>
                          <span ui-badge variant="outline" class="text-xs font-normal">+&#36;200/mo</span>
                        </div>
                        <p class="text-muted-foreground text-xs">
                          Direct Slack channel, named technical account manager &amp; 1-hour response SLA.
                        </p>
                      </div>
                    </div>
                    <div class="flex sm:justify-end">
                      <button
                        ui-switch
                        aria-label="Toggle dedicated support manager add-on"
                        [checked]="addonSupport()"
                        (checkedChange)="addonSupport.set($event)"
                      ></button>
                    </div>
                  </div>
    
                  <div
                    class="border-border bg-card hover:border-primary/30 flex flex-col justify-between gap-4 rounded-lg border p-4 transition-colors sm:flex-row sm:items-center"
                  >
                    <div class="flex items-start gap-3">
                      <div
                        class="bg-primary/10 text-primary border-primary/20 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border"
                      >
                        <lucide-icon [img]="ShieldCheck" class="size-4" />
                      </div>
                      <div class="space-y-0.5">
                        <div class="flex items-center gap-2">
                          <span class="text-foreground text-sm font-medium">Custom SLA Guarantee</span>
                          <span ui-badge variant="outline" class="text-xs font-normal">+&#36;500/mo</span>
                        </div>
                        <p class="text-muted-foreground text-xs">
                          99.99% uptime guarantee with financial commitments and priority disaster recovery.
                        </p>
                      </div>
                    </div>
                    <div class="flex sm:justify-end">
                      <button
                        ui-switch
                        aria-label="Toggle custom SLA guarantee add-on"
                        [checked]="addonSla()"
                        (checkedChange)="addonSla.set($event)"
                      ></button>
                    </div>
                  </div>
    
                  <div
                    class="border-border bg-card hover:border-primary/30 flex flex-col justify-between gap-4 rounded-lg border p-4 transition-colors sm:flex-row sm:items-center"
                  >
                    <div class="flex items-start gap-3">
                      <div
                        class="bg-primary/10 text-primary border-primary/20 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border"
                      >
                        <lucide-icon [img]="KeyRound" class="size-4" />
                      </div>
                      <div class="space-y-0.5">
                        <div class="flex items-center gap-2">
                          <span class="text-foreground text-sm font-medium">Single Sign-On (SSO / SAML)</span>
                          <span ui-badge variant="outline" class="text-xs font-normal">+&#36;100/mo</span>
                        </div>
                        <p class="text-muted-foreground text-xs">
                          Okta, Azure AD, Google Workspace, and SAML 2.0 enterprise identity integration.
                        </p>
                      </div>
                    </div>
                    <div class="flex sm:justify-end">
                      <button
                        ui-switch
                        aria-label="Toggle SSO add-on"
                        [checked]="addonSso()"
                        (checkedChange)="addonSso.set($event)"
                      ></button>
                    </div>
                  </div>
                </div>
              </div>
            </div>
    
            <div class="lg:sticky lg:top-8 lg:col-span-5">
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-4">
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                      Estimated Investment
                    </span>
                    <span ui-badge [variant]="recommendedPlan().variant" class="gap-1 shadow-xs">
                      @if (recommendedPlan().name === 'Enterprise') {
                        <lucide-icon [img]="ShieldCheck" class="size-3" />
                      }
                      {{ recommendedPlan().badge }}
                    </span>
                  </div>
                  <div class="mt-4">
                    <div class="flex items-baseline gap-1.5">
                      <span class="text-foreground text-4xl font-bold tracking-tight tabular-nums sm:text-5xl">
                        &#36;{{ totalMonthly() }}
                      </span>
                      <span class="text-muted-foreground text-sm font-normal"> / month</span>
                    </div>
                    @if (isAnnual()) {
                      <p class="text-muted-foreground mt-2 text-xs">
                        Billed annually (&#36;{{ totalAnnual().toLocaleString('en-US') }}/yr) ·
                        <span class="text-success font-semibold"
                          >Save &#36;{{ annualSavings().toLocaleString('en-US') }}/yr</span
                        >
                      </p>
                    } @else {
                      <p class="text-muted-foreground mt-2 text-xs">
                        Billed monthly · Switch to annual to save 20% (&#36;{{
                          annualSavings().toLocaleString('en-US')
                        }}/yr)
                      </p>
                    }
                  </div>
                </div>
                <div ui-card-content class="space-y-4">
                  <div ui-separator></div>
                  <div class="space-y-2.5">
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Base platform ({{ recommendedPlan().name }})</span>
                      <span class="font-medium tabular-nums">&#36;{{ annualAdjusted(baseCost()) }}/mo</span>
                    </div>
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Team seats ({{ seats() }} × &#36;{{ seatPrice }})</span>
                      <span class="font-medium tabular-nums">&#36;{{ annualAdjusted(seatsCost()) }}/mo</span>
                    </div>
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">API throughput ({{ currentRequestTier().label }})</span>
                      <span class="font-medium tabular-nums">
                        {{ requestsCost() === 0 ? 'Included' : '$' + annualAdjusted(requestsCost()) + '/mo' }}
                      </span>
                    </div>
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Cloud storage ({{ currentStorageTier().label }})</span>
                      <span class="font-medium tabular-nums"> &#36;{{ annualAdjusted(storageCost()) }}/mo </span>
                    </div>
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Add-ons ({{ activeAddonsCount() }} active)</span>
                      <span class="font-medium tabular-nums">
                        {{ addonsCost() === 0 ? '$0/mo' : '$' + annualAdjusted(addonsCost()) + '/mo' }}
                      </span>
                    </div>
                    @if (isAnnual()) {
                      <div
                        class="bg-success/10 text-success flex items-center justify-between rounded-md px-2.5 py-1.5 text-xs font-medium"
                      >
                        <span>Annual discount applied</span>
                        <span class="font-semibold tabular-nums">20% off</span>
                      </div>
                    }
                  </div>
                  <div ui-separator></div>
                  <ul class="text-muted-foreground space-y-2 text-xs">
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" />
                      <span>14-day fully featured free trial</span>
                    </li>
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" />
                      <span>No credit card required upfront</span>
                    </li>
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" />
                      <span>Zero-downtime migration assistance</span>
                    </li>
                  </ul>
                </div>
                <div ui-card-footer class="flex flex-col gap-2.5 pt-2">
                  <button ui-button class="w-full gap-2 font-semibold shadow-xs" size="lg">
                    Start 14-Day Free Trial
                    <lucide-icon [img]="ArrowRight" class="size-4" />
                  </button>
                  <button ui-button variant="outline" class="w-full" size="default">Request Custom Quote</button>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiPricingCalculatorComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly Check = Check
      protected readonly HardDrive = HardDrive
      protected readonly Headphones = Headphones
      protected readonly KeyRound = KeyRound
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Users = Users
      protected readonly Zap = Zap
    
      protected readonly seatPrice = SEAT_PRICE
      protected readonly seatMarks: Record<number, string> = { 1: '1', 25: '25', 50: '50', 75: '75', 100: '100' }
      protected readonly requestMarks: Record<number, string> = { 0: '10K', 2: '100K', 4: '500K', 7: '5M' }
      protected readonly storageMarks: Record<number, string> = { 0: '100 GB', 2: '500 GB', 4: '2 TB', 6: '10 TB' }
    
      @Input() set initialAnnual(v: boolean) {
        this.isAnnual.set(!!v)
      }
      @Input('class') className?: string
    
      readonly isAnnual = signal(true)
      readonly seats = signal(12)
      readonly requestIndex = signal(2)
      readonly storageIndex = signal(2)
      readonly addonSupport = signal(false)
      readonly addonSla = signal(false)
      readonly addonSso = signal(true)
    
      readonly currentRequestTier = computed(() => REQUEST_TIERS[this.requestIndex()] ?? REQUEST_TIERS[0]!)
      readonly currentStorageTier = computed(() => STORAGE_TIERS[this.storageIndex()] ?? STORAGE_TIERS[0]!)
    
      readonly seatTier = computed(() => {
        if (this.seats() <= 5) return { label: 'Starter Team', variant: 'secondary' as const }
        if (this.seats() <= 25) return { label: 'Growth Team', variant: 'outline' as const }
        return { label: 'Scale Team', variant: 'default' as const }
      })
    
      readonly recommendedPlan = computed(() => {
        if (this.addonSla() || this.requestIndex() >= 6 || this.storageIndex() >= 5 || this.seats() >= 30) {
          return { name: 'Enterprise', badge: 'Enterprise Plan', baseFee: 199, variant: 'default' as const }
        }
        if (this.seats() >= 8 || this.requestIndex() >= 3 || this.storageIndex() >= 3 || this.addonSupport()) {
          return { name: 'Growth', badge: 'Growth Plan', baseFee: 79, variant: 'default' as const }
        }
        return { name: 'Starter', badge: 'Starter Plan', baseFee: 29, variant: 'secondary' as const }
      })
    
      readonly baseCost = computed(() => this.recommendedPlan().baseFee)
      readonly seatsCost = computed(() => this.seats() * SEAT_PRICE)
      readonly requestsCost = computed(() => this.currentRequestTier().cost)
      readonly storageCost = computed(() => this.currentStorageTier().cost)
      readonly addonsCost = computed(() => {
        let cost = 0
        if (this.addonSupport()) cost += SUPPORT_MANAGER_PRICE
        if (this.addonSla()) cost += CUSTOM_SLA_PRICE
        if (this.addonSso()) cost += SSO_PRICE
        return cost
      })
    
      readonly activeAddonsCount = computed(() => {
        let count = 0
        if (this.addonSupport()) count++
        if (this.addonSla()) count++
        if (this.addonSso()) count++
        return count
      })
    
      readonly subtotalMonthly = computed(
        () => this.baseCost() + this.seatsCost() + this.requestsCost() + this.storageCost() + this.addonsCost(),
      )
    
      readonly discountMultiplier = computed(() => (this.isAnnual() ? 0.8 : 1.0))
      readonly totalMonthly = computed(() => Math.round(this.subtotalMonthly() * this.discountMultiplier()))
      readonly totalAnnual = computed(() => this.totalMonthly() * 12)
      readonly annualSavings = computed(() => Math.round(this.subtotalMonthly() * 0.2 * 12))
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-10 p-4 sm:p-6 lg:p-8', this.className)
      }
    
      billingLabelClass(active: boolean): string {
        return cn(
          'text-xs font-medium transition-colors',
          active ? 'text-foreground font-semibold' : 'text-muted-foreground',
        )
      }
    
      annualAdjusted(cost: number): number {
        return this.isAnnual() ? Math.round(cost * 0.8) : cost
      }
    
      onSeatsChange(value: number[]): void {
        const next = value[0]
        if (next !== undefined) this.seats.set(next)
      }
    
      onRequestChange(value: number[]): void {
        const next = value[0]
        if (next !== undefined) this.requestIndex.set(next)
      }
    
      onStorageChange(value: number[]): void {
        const next = value[0]
        if (next !== undefined) this.storageIndex.set(next)
      }
    }
    
  • src/app/components/blocks/pricing-calculator/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/pricing-calculator.json