Menu

Change language

GitHub repo

Pricing Feature Addon Builder

blockpricing

Interactive add-on pricing calculator with tier bundling, seat scaling, and real-time invoice generation.

Installation

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

Variants

Loading interactive previews…

Schema

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

PricingPlan
interface PricingPlan {
  id: string
  name: string
  monthlyPrice: number
  description: string
  includedSeats: number
}
PricingAddon
interface PricingAddon {
  id: string
  name: string
  monthlyPrice: number
  description: string
  category: string
}

npm dependencies

Used by

Files installed (2)

  • src/app/components/blocks/pricing-feature-addon-builder/pricing-feature-addon-builder.component.ts17.2 kB
    // Interactive add-on pricing calculator: tier bundling, seat scaling, and real-time
    // invoice generation with an annual billing toggle. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import { ArrowRight, Calculator, Check, CreditCard, LucideAngularModule } 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 } from '@/ui/card/card.component'
    
    export interface PricingPlan {
      id: string
      name: string
      monthlyPrice: number
      description: string
      includedSeats: number
    }
    
    export interface PricingAddon {
      id: string
      name: string
      monthlyPrice: number
      description: string
      category: string
    }
    
    const DEFAULT_PLANS: PricingPlan[] = [
      {
        id: 'starter',
        name: 'Starter Tier',
        monthlyPrice: 29,
        description: 'For indie developers and early-stage prototypes needing unbundled speed.',
        includedSeats: 2,
      },
      {
        id: 'growth',
        name: 'Growth Core',
        monthlyPrice: 99,
        description: 'For scaling product engineering teams building high-conversion platforms.',
        includedSeats: 5,
      },
      {
        id: 'scale',
        name: 'Scale Enterprise',
        monthlyPrice: 299,
        description: 'For mission-critical production clusters requiring sub-10ms global edge delivery.',
        includedSeats: 15,
      },
    ]
    
    const DEFAULT_ADDONS: PricingAddon[] = [
      {
        id: 'dedicated-ip',
        name: 'Dedicated Static Edge IP',
        monthlyPrice: 49,
        description: 'Static IPv4/IPv6 address allocations with zero-reputation penalty.',
        category: 'Network',
      },
      {
        id: 'audit-logs',
        name: 'Immutable SOC2 Audit Logs',
        monthlyPrice: 79,
        description: 'Cryptographically signed telemetry logs with 365-day cold storage retention.',
        category: 'Security',
      },
      {
        id: 'multi-region',
        name: 'Multi-Region Active-Active Mesh',
        monthlyPrice: 129,
        description: 'Synchronized cross-continental database replicas and automatic DNS failover.',
        category: 'Reliability',
      },
      {
        id: 'priority-sla',
        name: '1-Hour Enterprise Response SLA',
        monthlyPrice: 199,
        description: 'Direct Slack / Discord hotline with senior design engineering staff.',
        category: 'Support',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-pricing-feature-addon-builder, [ui-pricing-feature-addon-builder]',
      standalone: true,
      // React renders the root <section> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent],
      template: `
        <section data-slot="pricing-feature-addon-builder" [class]="rootClass">
          <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
            <!-- Section Header -->
            <div class="mx-auto max-w-3xl space-y-4 text-center">
              <a
                href="#pricing-calculator"
                class="group border-border/80 bg-secondary/60 hover:bg-secondary text-foreground inline-flex items-center gap-2 rounded-full border px-3.5 py-1 text-xs font-medium shadow-2xs transition-colors"
              >
                <lucide-icon [img]="Calculator" class="text-primary size-3.5" />
                <span>Real-time Add-on Cost Synthesizer</span>
                <lucide-icon
                  [img]="ArrowRight"
                  class="text-muted-foreground size-3 transition-transform group-hover:translate-x-0.5"
                />
              </a>
    
              <h2 class="text-foreground text-3xl font-bold tracking-tight sm:text-4xl">
                {{ title() }}
              </h2>
    
              <p class="text-muted-foreground text-base sm:text-lg">
                {{ description() }}
              </p>
    
              <!-- Billing Cadence Toggle -->
              <div class="flex items-center justify-center gap-3 pt-2">
                <span [class]="cadenceLabelClass(!isAnnual())">Monthly Billing</span>
                <button
                  type="button"
                  aria-label="Toggle annual billing"
                  [class]="cadenceTrackClass()"
                  (click)="isAnnual.set(!isAnnual())"
                >
                  <span aria-hidden="true" [class]="cadenceThumbClass()"></span>
                </button>
                <span [class]="cadenceAnnualLabelClass()">
                  <span>Annual Billing</span>
                  <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs">
                    Save 20%
                  </span>
                </span>
              </div>
            </div>
    
            <!-- Add-on Builder Workbench -->
            <div class="mt-12 grid grid-cols-1 gap-6 lg:grid-cols-12">
              <!-- Left: Plan Selection & Addon Toggles (7 Cols) -->
              <div class="space-y-6 lg:col-span-7">
                <!-- Step 1: Base Tier Cards -->
                <div class="space-y-3">
                  <div class="text-muted-foreground text-xs font-bold tracking-wider uppercase">
                    Step 1: Choose Base Core Tier
                  </div>
                  <div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
                    @for (plan of activePlans(); track plan.id) {
                      <button type="button" [class]="planCardClass(plan.id)" (click)="selectedPlanId.set(plan.id)">
                        <div>
                          <div class="text-foreground text-xs font-bold">{{ plan.name }}</div>
                          <div class="text-foreground mt-1 font-mono text-lg font-bold">
                            &#36;{{ plan.monthlyPrice }}<span class="text-muted-foreground text-xs font-normal">/mo</span>
                          </div>
                        </div>
                        <div class="text-muted-foreground mt-2 text-xs">
                          Includes {{ plan.includedSeats }} engineer seats
                        </div>
                      </button>
                    }
                  </div>
                </div>
    
                <!-- Step 2: Seat Allocation Slider -->
                <div ui-card class="border-border bg-card/60 shadow-2xs">
                  <div ui-card-content class="space-y-2.5 p-4">
                    <div class="flex items-center justify-between text-xs">
                      <span class="text-foreground font-bold">Engineer Team Seats</span>
                      <span class="text-foreground font-mono text-sm font-bold"
                        >{{ seatCount() }} seats (&#36;{{ extraSeatCost() }}/mo extra)</span
                      >
                    </div>
                    <input
                      type="range"
                      min="2"
                      max="50"
                      step="1"
                      aria-label="Engineer team seats"
                      [value]="seatCount()"
                      (input)="onSeatInput($event)"
                      class="accent-primary w-full cursor-pointer"
                    />
                    <div class="text-muted-foreground flex justify-between font-mono text-xs">
                      <span>2 seats</span>
                      <span>50 seats</span>
                    </div>
                  </div>
                </div>
    
                <!-- Step 3: Enterprise Add-ons Checklist -->
                <div class="space-y-3">
                  <div class="text-muted-foreground text-xs font-bold tracking-wider uppercase">
                    Step 3: Select Modular Capabilities
                  </div>
                  <div class="grid grid-cols-1 gap-2.5">
                    @for (addon of activeAddons(); track addon.id) {
                      <button type="button" [class]="addonCardClass(addon.id)" (click)="toggleAddon(addon.id)">
                        <div class="flex min-w-0 items-center gap-3">
                          <div [class]="addonCheckClass(addon.id)">
                            @if (isAddonSelected(addon.id)) {
                              <lucide-icon [img]="Check" class="size-3.5" />
                            }
                          </div>
                          <div class="min-w-0 space-y-0.5">
                            <div class="flex items-center gap-2">
                              <span class="text-foreground text-xs font-bold">{{ addon.name }}</span>
                              <span ui-badge variant="outline" class="border-border text-muted-foreground text-xs">
                                {{ addon.category }}
                              </span>
                            </div>
                            <div class="text-muted-foreground truncate text-xs">{{ addon.description }}</div>
                          </div>
                        </div>
    
                        <div class="text-foreground shrink-0 pl-2 font-mono text-xs font-bold">
                          +&#36;{{ addon.monthlyPrice }}<span class="text-muted-foreground text-xs font-normal">/mo</span>
                        </div>
                      </button>
                    }
                  </div>
                </div>
              </div>
    
              <!-- Right: Real-time Invoice Estimate Card (5 Cols) -->
              <div class="lg:col-span-5">
                <div ui-card class="border-border bg-card sticky top-8 shadow-sm">
                  <div ui-card-content class="space-y-6 p-6">
                    <div class="border-border flex items-center justify-between border-b pb-3">
                      <div class="flex items-center gap-2">
                        <lucide-icon [img]="CreditCard" class="text-primary size-4" />
                        <span class="text-foreground text-sm font-semibold">Estimated Monthly Invoice</span>
                      </div>
                      <span ui-badge variant="outline" class="border-border text-primary font-mono text-xs">
                        {{ isAnnual() ? 'Annualized' : 'Monthly' }}
                      </span>
                    </div>
    
                    <!-- Price Breakdown List -->
                    <div class="space-y-3 text-xs">
                      <div class="text-muted-foreground flex justify-between">
                        <span>{{ selectedPlan().name }}</span>
                        <span class="text-foreground font-mono">&#36;{{ selectedPlan().monthlyPrice }}.00</span>
                      </div>
    
                      @if (extraSeats() > 0) {
                        <div class="text-muted-foreground flex justify-between">
                          <span>Extra Seats ({{ extraSeats() }} × &#36;15)</span>
                          <span class="text-foreground font-mono">&#36;{{ extraSeatCost() }}.00</span>
                        </div>
                      }
    
                      @for (addon of selectedAddons(); track addon.id) {
                        <div class="text-muted-foreground flex justify-between">
                          <span class="truncate pr-2">{{ addon.name }}</span>
                          <span class="text-foreground font-mono">&#36;{{ addon.monthlyPrice }}.00</span>
                        </div>
                      }
    
                      @if (isAnnual()) {
                        <div class="border-border/60 text-success flex justify-between border-t pt-2 font-medium">
                          <span>Annual Billing Discount (20%)</span>
                          <span class="font-mono">−&#36;{{ monthlySubtotal() - finalMonthlyRate() }}.00</span>
                        </div>
                      }
                    </div>
    
                    <!-- Total Sum Band -->
                    <div class="border-border bg-muted/40 space-y-1 rounded-lg border p-4">
                      <div class="text-muted-foreground text-xs font-medium">Net Monthly Investment</div>
                      <div class="flex items-baseline gap-1.5">
                        <span class="text-foreground font-mono text-3xl font-bold tracking-tight">
                          &#36;{{ finalMonthlyRate() }}
                        </span>
                        <span class="text-muted-foreground font-mono text-xs">/ month</span>
                      </div>
                      @if (isAnnual()) {
                        <div class="text-success text-xs font-medium">
                          Billed annually (&#36;{{ finalMonthlyRate() * 12 }}/yr • Save &#36;{{ annualSavings() }}/yr)
                        </div>
                      }
                    </div>
    
                    <button ui-button class="w-full gap-2 shadow-xs" size="lg">
                      <span>Start 14-Day Free Evaluation</span>
                      <lucide-icon [img]="ArrowRight" class="size-4" />
                    </button>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </section>
      `,
    })
    export class UiPricingFeatureAddonBuilderComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly Calculator = Calculator
      protected readonly Check = Check
      protected readonly CreditCard = CreditCard
    
      @Input('title') set titleInput(v: string) {
        if (v !== undefined) this.titleSignal.set(v)
      }
      @Input('description') set descriptionInput(v: string) {
        if (v !== undefined) this.descriptionSignal.set(v)
      }
      @Input('plans') set plansInput(v: PricingPlan[] | undefined) {
        this.plansSignal.set(v)
      }
      @Input('addons') set addonsInput(v: PricingAddon[] | undefined) {
        this.addonsSignal.set(v)
      }
      @Input('class') className?: string
    
      private readonly titleSignal = signal('Build your custom plan with transparent, zero-surprise pricing.')
      private readonly descriptionSignal = signal(
        'Select your base tier, adjust seat allocations, and toggle modular enterprise add-ons with real-time invoice calculations.',
      )
      private readonly plansSignal = signal<PricingPlan[] | undefined>(undefined)
      private readonly addonsSignal = signal<PricingAddon[] | undefined>(undefined)
    
      readonly title = computed(() => this.titleSignal())
      readonly description = computed(() => this.descriptionSignal())
      readonly activePlans = computed(() => this.plansSignal() ?? DEFAULT_PLANS)
      readonly activeAddons = computed(() => this.addonsSignal() ?? DEFAULT_ADDONS)
    
      readonly selectedPlanId = signal('growth')
      readonly selectedAddonIds = signal<string[]>(['dedicated-ip', 'audit-logs'])
      readonly seatCount = signal(8)
      readonly isAnnual = signal(true)
    
      readonly selectedPlan = computed(() => {
        return this.activePlans().find((p) => p.id === this.selectedPlanId()) ?? this.activePlans()[0]!
      })
    
      readonly extraSeats = computed(() => Math.max(0, this.seatCount() - this.selectedPlan().includedSeats))
      readonly extraSeatCost = computed(() => this.extraSeats() * 15)
    
      readonly selectedAddons = computed(() => this.activeAddons().filter((a) => this.selectedAddonIds().includes(a.id)))
    
      readonly totalAddonsCost = computed(() => this.selectedAddons().reduce((sum, a) => sum + a.monthlyPrice, 0))
    
      readonly monthlySubtotal = computed(
        () => this.selectedPlan().monthlyPrice + this.extraSeatCost() + this.totalAddonsCost(),
      )
    
      readonly finalMonthlyRate = computed(() =>
        this.isAnnual() ? Math.round(this.monthlySubtotal() * 0.8) : this.monthlySubtotal(),
      )
    
      readonly annualSavings = computed(() => (this.monthlySubtotal() - Math.round(this.monthlySubtotal() * 0.8)) * 12)
    
      get rootClass(): string {
        return cn('bg-background relative overflow-hidden py-16 sm:py-24', this.className)
      }
    
      cadenceLabelClass(active: boolean): string {
        return cn('text-xs font-medium', active ? 'text-foreground font-semibold' : 'text-muted-foreground')
      }
    
      cadenceAnnualLabelClass(): string {
        return cn(
          'flex items-center gap-1.5 text-xs font-medium',
          this.isAnnual() ? 'text-foreground font-semibold' : 'text-muted-foreground',
        )
      }
    
      cadenceTrackClass(): string {
        return cn(
          'bg-muted focus-visible:ring-ring relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:outline-none',
          this.isAnnual() ? 'bg-primary' : 'bg-muted',
        )
      }
    
      cadenceThumbClass(): string {
        return cn(
          'bg-background pointer-events-none inline-block size-5 transform rounded-full shadow-lg ring-0 transition duration-200 ease-in-out',
          this.isAnnual() ? 'translate-x-5' : 'translate-x-0',
        )
      }
    
      planCardClass(planId: string): string {
        return cn(
          'flex flex-col justify-between rounded-xl border p-4 text-left transition-colors',
          this.selectedPlanId() === planId
            ? 'border-primary bg-primary/5 ring-primary/20 shadow-xs ring-1'
            : 'border-border bg-card hover:bg-muted/40 text-muted-foreground hover:text-foreground',
        )
      }
    
      addonCardClass(addonId: string): string {
        return cn(
          'flex items-center justify-between rounded-xl border p-3.5 text-left transition-colors',
          this.isAddonSelected(addonId)
            ? 'border-primary/60 bg-primary/5 shadow-2xs'
            : 'border-border bg-card hover:bg-muted/30 text-muted-foreground hover:text-foreground',
        )
      }
    
      addonCheckClass(addonId: string): string {
        return cn(
          'flex size-5 shrink-0 items-center justify-center rounded border transition-colors',
          this.isAddonSelected(addonId)
            ? 'border-primary bg-primary text-primary-foreground'
            : 'border-border bg-background',
        )
      }
    
      isAddonSelected(addonId: string): boolean {
        return this.selectedAddonIds().includes(addonId)
      }
    
      toggleAddon(addonId: string): void {
        if (this.isAddonSelected(addonId)) {
          this.selectedAddonIds.set(this.selectedAddonIds().filter((id) => id !== addonId))
        } else {
          this.selectedAddonIds.set([...this.selectedAddonIds(), addonId])
        }
      }
    
      onSeatInput(event: Event): void {
        const value = Number((event.target as HTMLInputElement).value)
        if (Number.isFinite(value)) this.seatCount.set(value)
      }
    }
    
  • src/app/components/blocks/pricing-feature-addon-builder/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/pricing-feature-addon-builder.json