Menu

Change language

GitHub repo

Virtual Card Manager

blockfinance

Mercury/Ramp-style corporate virtual card manager: interactive dark credit card visual hero with EMV chip, revealable credentials, live spending limit progress bar, instant freeze toggles, limit adjusters, and an active virtual cards table.

Installation

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

Variants

Loading interactive previews…

Schema

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

VirtualCard
interface VirtualCard {
  id: string
  name: string
  brand: 'mastercard' | 'visa'
  last4: string
  fullNumber: string
  holder: string
  avatar: string
  initials: string
  expiry: string
  cvv: string
  spent: number
  limit: number
  type: 'Monthly Recurring' | 'Single-use'
  status: 'Active' | 'Frozen'
  billingCycle: string
  purpose: string
}

Files installed (2)

  • src/app/components/blocks/virtual-card-manager/virtual-card-manager.component.ts28.6 kB
    // Mercury / Ramp style corporate virtual card manager: interactive dark credit card
    // visual hero with EMV chip, revealable credentials, live spending limit progress,
    // instant freeze toggles, limit adjusters, and an active virtual cards table.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      Check,
      CheckCircle2,
      Copy,
      CreditCard,
      Eye,
      EyeOff,
      LucideAngularModule,
      Plus,
      Radio,
      ShieldCheck,
      SlidersHorizontal,
      Snowflake,
      Trash2,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    
    export interface VirtualCard {
      id: string
      name: string
      brand: 'mastercard' | 'visa'
      last4: string
      fullNumber: string
      holder: string
      avatar: string
      initials: string
      expiry: string
      cvv: string
      spent: number
      limit: number
      type: 'Monthly Recurring' | 'Single-use'
      status: 'Active' | 'Frozen'
      billingCycle: string
      purpose: string
    }
    
    const initialCards: VirtualCard[] = [
      {
        id: 'vc-1',
        name: 'AWS & Cloud Infrastructure',
        brand: 'mastercard',
        last4: '8492',
        fullNumber: '5532 8920 4108 8492',
        holder: 'Elena Rostova',
        avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
        initials: 'ER',
        expiry: '08/29',
        cvv: '482',
        spent: 4200,
        limit: 5000,
        type: 'Monthly Recurring',
        status: 'Active',
        billingCycle: 'Resets on 1st of month',
        purpose: 'Cloud compute, S3 buckets, and RDS instances',
      },
      {
        id: 'vc-2',
        name: 'Marketing & Ad Spend',
        brand: 'visa',
        last4: '3190',
        fullNumber: '4012 8831 9204 3190',
        holder: 'Marcus Chen',
        avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&auto=format&fit=crop&q=80',
        initials: 'MC',
        expiry: '11/28',
        cvv: '915',
        spent: 8750,
        limit: 10000,
        type: 'Monthly Recurring',
        status: 'Active',
        billingCycle: 'Resets on 1st of month',
        purpose: 'Google Ads, LinkedIn campaigns, and Meta Ads',
      },
      {
        id: 'vc-3',
        name: 'Travel & Conferences',
        brand: 'visa',
        last4: '6021',
        fullNumber: '4111 5900 1284 6021',
        holder: 'Sarah Jenkins',
        avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&auto=format&fit=crop&q=80',
        initials: 'SJ',
        expiry: '03/27',
        cvv: '304',
        spent: 1850,
        limit: 2500,
        type: 'Single-use',
        status: 'Frozen',
        billingCycle: 'Single event allowance',
        purpose: 'Q3 Design Systems Summit flights and lodging',
      },
      {
        id: 'vc-4',
        name: 'SaaS Subscriptions',
        brand: 'mastercard',
        last4: '9455',
        fullNumber: '5241 7719 3302 9455',
        holder: 'Elena Rostova',
        avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
        initials: 'ER',
        expiry: '05/29',
        cvv: '729',
        spent: 620,
        limit: 1200,
        type: 'Monthly Recurring',
        status: 'Active',
        billingCycle: 'Resets on 1st of month',
        purpose: 'GitHub Enterprise, Figma, and Slack licenses',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-virtual-card-manager, [ui-virtual-card-manager]',
      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,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiProgressComponent,
        UiSwitchComponent,
      ],
      template: `
        <div data-slot="virtual-card-manager" [class]="rootClass">
          <!-- Header -->
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1">
              <div class="flex items-center gap-2.5">
                <h2 class="text-foreground text-xl font-semibold tracking-tight sm:text-2xl">Virtual Cards</h2>
                <span ui-badge wrap variant="secondary" class="font-mono text-xs tabular-nums">
                  {{ cards().length }} Cards
                </span>
              </div>
              <p class="text-muted-foreground text-sm">
                Generate instant corporate cards for subscriptions, vendors, and team expense limits.
              </p>
            </div>
    
            <div class="flex items-center gap-2">
              <button ui-button size="sm" class="gap-1.5 shadow-xs" (click)="issueNewCard()">
                <lucide-icon [img]="Plus" class="size-4" />
                Issue New Card
              </button>
            </div>
          </div>
    
          <!-- Notification / Action Toast feedback -->
          @if (actionFeedback()) {
            <div
              class="bg-primary/10 text-primary border-primary/20 flex items-center justify-between rounded-lg border px-4 py-2.5 text-xs font-medium transition-colors"
            >
              <div class="flex items-center gap-2">
                <lucide-icon [img]="CheckCircle2" class="size-4 shrink-0" />
                <span>{{ actionFeedback() }}</span>
              </div>
              <button
                ui-button
                aria-label="Dismiss notification"
                variant="ghost"
                size="xs"
                class="h-6 px-2 text-xs"
                (click)="actionFeedback.set(null)"
              >
                Dismiss
              </button>
            </div>
          }
    
          <!-- Virtual Card Visual Hero -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="border-b pb-4">
              <div class="flex flex-wrap items-center justify-between gap-2">
                <div>
                  <div class="flex items-center gap-2">
                    <h3 ui-card-title class="text-base font-semibold">{{ selectedCard().name }}</h3>
                    <span
                      ui-badge
                      wrap
                      [variant]="selectedCard().status === 'Active' ? 'success' : 'warning'"
                      class="text-xs whitespace-normal"
                    >
                      {{ selectedCard().status }}
                    </span>
                  </div>
                  <p ui-card-description class="mt-0.5 text-xs">
                    {{ selectedCard().purpose }} · {{ selectedCard().billingCycle }}
                  </p>
                </div>
                <span ui-badge wrap variant="outline" class="font-mono text-xs">
                  Ending in {{ selectedCard().last4 }}
                </span>
              </div>
            </div>
    
            <div ui-card-content class="pt-6">
              <div class="grid gap-6 lg:grid-cols-12 lg:items-center">
                <!-- Stylized Dark Credit Card Container -->
                <div class="lg:col-span-6 xl:col-span-5">
                  <div [class]="cardVisualClass">
                    <!-- Ambient light accents -->
                    <div
                      class="bg-chart-1/15 pointer-events-none absolute -top-16 -right-16 size-48 rounded-full blur-2xl"
                      aria-hidden="true"
                    ></div>
                    <div
                      class="bg-success/10 pointer-events-none absolute -bottom-16 -left-16 size-48 rounded-full blur-2xl"
                      aria-hidden="true"
                    ></div>
    
                    <!-- Frozen watermark overlay -->
                    @if (isSelectedFrozen()) {
                      <div
                        class="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-2xl bg-zinc-950/70 backdrop-blur-xs"
                      >
                        <div
                          class="border-warning/40 bg-warning/20 text-warning inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider uppercase"
                        >
                          <lucide-icon [img]="Snowflake" class="size-3.5" />
                          Card Frozen
                        </div>
                        <p class="text-muted-foreground text-xs">Transactions are currently blocked</p>
                      </div>
                    }
    
                    <!-- Top Row: Chip & Contactless + Brand Logo -->
                    <div class="relative z-10 flex items-center justify-between">
                      <div class="flex items-center gap-3">
                        <!-- Metallic EMV Chip -->
                        <div
                          class="border-warning/20/40 relative flex h-8 w-11 items-center justify-center rounded-md border bg-gradient-to-br from-amber-200/30 via-amber-400/20 to-amber-600/30 shadow-inner"
                          aria-hidden="true"
                        >
                          <div class="border-warning/20/30 absolute inset-0 grid grid-cols-3 grid-rows-2">
                            <div class="border-warning/30 border-r border-b"></div>
                            <div class="border-warning/30 border-r border-b"></div>
                            <div class="border-warning/30 border-b"></div>
                            <div class="border-warning/30 border-r"></div>
                            <div class="border-warning/30 border-r"></div>
                            <div></div>
                          </div>
                        </div>
    
                        <!-- NFC Wave icon -->
                        <div class="text-muted-foreground/80 rotate-90" aria-hidden="true">
                          <lucide-icon [img]="Radio" class="size-4" />
                        </div>
                      </div>
    
                      <!-- Brand Indicator -->
                      <div class="flex items-center gap-2">
                        <span class="text-muted-foreground font-mono text-xs font-medium tracking-wider uppercase"
                          >Virtual</span
                        >
                        @if (selectedCard().brand === 'mastercard') {
                          <div class="flex -space-x-2" aria-label="Mastercard">
                            <div class="bg-destructive/90 size-6 rounded-full"></div>
                            <div class="bg-warning/90 size-6 rounded-full mix-blend-screen"></div>
                          </div>
                        } @else {
                          <div class="text-base font-bold tracking-widest text-zinc-100 italic" aria-label="Visa">VISA</div>
                        }
                      </div>
                    </div>
    
                    <!-- Middle Row: Card Number & Quick Actions -->
                    <div class="relative z-10 my-auto pt-4">
                      <div class="flex items-center justify-between gap-2">
                        <p
                          class="font-mono text-lg font-medium tracking-[0.16em] text-zinc-100 tabular-nums drop-shadow-sm sm:text-xl"
                        >
                          {{ showDetails() ? selectedCard().fullNumber : '•••• •••• •••• ' + selectedCard().last4 }}
                        </p>
                        <div class="flex items-center gap-1">
                          <button
                            ui-button
                            variant="ghost"
                            size="icon-sm"
                            class="size-7 text-zinc-300 hover:bg-white/10 hover:text-white"
                            [attr.aria-label]="showDetails() ? 'Hide card number' : 'Reveal card number'"
                            (click)="showDetails.set(!showDetails())"
                          >
                            @if (showDetails()) {
                              <lucide-icon [img]="EyeOff" class="size-3.5" />
                            } @else {
                              <lucide-icon [img]="Eye" class="size-3.5" />
                            }
                          </button>
                          <button
                            ui-button
                            variant="ghost"
                            size="icon-sm"
                            class="size-7 text-zinc-300 hover:bg-white/10 hover:text-white"
                            aria-label="Copy card number"
                            (click)="handleCopyNumber()"
                          >
                            @if (copied()) {
                              <lucide-icon [img]="Check" class="text-success size-3.5" />
                            } @else {
                              <lucide-icon [img]="Copy" class="size-3.5" />
                            }
                          </button>
                        </div>
                      </div>
                    </div>
    
                    <!-- Bottom Row: Cardholder, Expiry, CVV -->
                    <div class="relative z-10 flex items-end justify-between pt-2">
                      <div class="space-y-0.5">
                        <p class="text-muted-foreground font-mono text-xs tracking-wider uppercase">Cardholder</p>
                        <p class="max-w-[140px] truncate text-xs font-semibold tracking-wide text-zinc-200 uppercase">
                          {{ selectedCard().holder }}
                        </p>
                      </div>
    
                      <div class="space-y-0.5">
                        <p class="text-muted-foreground font-mono text-xs tracking-wider uppercase">Expires</p>
                        <p class="font-mono text-xs font-medium text-zinc-200 tabular-nums">
                          {{ selectedCard().expiry }}
                        </p>
                      </div>
    
                      <div class="space-y-0.5 text-right">
                        <p class="text-muted-foreground font-mono text-xs tracking-wider uppercase">CVV</p>
                        <p class="font-mono text-xs font-medium text-zinc-200 tabular-nums">
                          {{ showDetails() ? selectedCard().cvv : '•••' }}
                        </p>
                      </div>
                    </div>
                  </div>
                </div>
    
                <!-- Card Controls & Spending Breakdown -->
                <div class="space-y-6 lg:col-span-6 xl:col-span-7">
                  <!-- Spending Limit Meter -->
                  <div class="border-border bg-muted/30 space-y-3 rounded-lg border p-4">
                    <div class="flex items-center justify-between">
                      <span class="text-sm font-medium">Monthly Spending Limit</span>
                      <span class="text-muted-foreground font-mono text-xs tabular-nums">
                        \${{ selectedCard().spent.toLocaleString() }} / \${{ selectedCard().limit.toLocaleString() }}
                      </span>
                    </div>
    
                    <ui-progress [value]="selectedPercent()" class="h-2" />
    
                    <div class="text-muted-foreground flex items-center justify-between text-xs">
                      <span class="text-foreground font-medium tabular-nums">
                        \${{ availableFunds().toLocaleString() }} available
                      </span>
                      <span class="font-medium tabular-nums"> {{ selectedPercent() }}% utilized </span>
                    </div>
                  </div>
    
                  <!-- Card Controls Row -->
                  <div class="grid gap-3 sm:grid-cols-3">
                    <!-- Freeze Card Toggle -->
                    <div
                      class="border-border bg-card flex items-center justify-between rounded-lg border p-3 sm:flex-col sm:items-start sm:justify-between sm:gap-2"
                    >
                      <div class="space-y-0.5">
                        <p class="text-foreground text-xs font-medium">Freeze Card</p>
                        <p class="text-muted-foreground text-xs">Block charges</p>
                      </div>
                      <ui-switch
                        size="sm"
                        aria-label="Toggle card freeze status"
                        [checked]="isSelectedFrozen()"
                        (checkedChange)="toggleFreeze(selectedCard().id)"
                      />
                    </div>
    
                    <!-- Change Limit Button -->
                    <button
                      ui-button
                      variant="outline"
                      size="sm"
                      class="h-auto flex-col items-start gap-1 p-3 text-left"
                      (click)="adjustLimit(selectedCard().id)"
                    >
                      <span class="flex w-full items-center justify-between">
                        <span class="text-foreground text-xs font-medium">Adjust Limit</span>
                        <lucide-icon [img]="SlidersHorizontal" class="text-muted-foreground size-3.5" />
                      </span>
                      <span class="text-muted-foreground text-xs font-normal">Edit monthly cap</span>
                    </button>
    
                    <!-- Terminate Card Button -->
                    <button
                      ui-button
                      variant="outline"
                      size="sm"
                      class="text-destructive hover:text-destructive hover:bg-destructive/10 border-border h-auto flex-col items-start gap-1 p-3 text-left"
                      (click)="terminateCard(selectedCard().id)"
                    >
                      <span class="flex w-full items-center justify-between">
                        <span class="text-xs font-medium">Terminate</span>
                        <lucide-icon [img]="Trash2" class="size-3.5" />
                      </span>
                      <span class="text-muted-foreground text-xs font-normal">Close permanently</span>
                    </button>
                  </div>
    
                  <!-- Card Meta Specs -->
                  <div class="grid grid-cols-2 gap-3 pt-1 sm:grid-cols-3">
                    <div class="space-y-0.5">
                      <span class="text-muted-foreground text-xs">Cardholder</span>
                      <div class="flex items-center gap-1.5 pt-0.5">
                        <ui-avatar size="xs" class="size-4.5">
                          <ui-avatar-image [src]="selectedCard().avatar" [alt]="selectedCard().holder" />
                          <ui-avatar-fallback class="text-xs">{{ selectedCard().initials }}</ui-avatar-fallback>
                        </ui-avatar>
                        <span class="truncate text-xs font-medium">{{ selectedCard().holder }}</span>
                      </div>
                    </div>
    
                    <div class="space-y-0.5">
                      <span class="text-muted-foreground text-xs">Card Type</span>
                      <p class="text-foreground text-xs font-medium">{{ selectedCard().type }}</p>
                    </div>
    
                    <div class="space-y-0.5">
                      <span class="text-muted-foreground text-xs">Fraud Protection</span>
                      <div class="text-success flex items-center gap-1 text-xs font-medium">
                        <lucide-icon [img]="ShieldCheck" class="size-3.5" />
                        <span>3D Secure Active</span>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Active Cards Grid / Table -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="border-b pb-4">
              <div class="flex items-center justify-between">
                <div>
                  <h3 ui-card-title class="text-base font-semibold">Active Cards List</h3>
                  <p ui-card-description class="mt-0.5 text-xs">
                    All company virtual cards currently assigned to team members and services.
                  </p>
                </div>
                <span ui-badge wrap variant="outline" class="font-mono text-xs tabular-nums">
                  {{ cards().length }} Issued
                </span>
              </div>
            </div>
    
            <div ui-card-content class="p-0">
              <div class="divide-border divide-y">
                @for (card of cards(); track card.id) {
                  <div [class]="rowClass(card)">
                    <!-- Card Identity & Number -->
                    <div class="flex min-w-0 items-center gap-3.5 sm:w-1/3">
                      <div [class]="cardIconClass(card)">
                        <lucide-icon [img]="CreditCard" class="size-5" />
                      </div>
    
                      <div class="min-w-0 flex-1 space-y-0.5">
                        <div class="flex items-center gap-2">
                          <p class="text-foreground truncate text-sm font-medium">{{ card.name }}</p>
                        </div>
                        <div class="text-muted-foreground flex items-center gap-2 text-xs">
                          <span class="font-mono tabular-nums">•••• {{ card.last4 }}</span>
                          <span>·</span>
                          <span class="uppercase">{{ card.brand }}</span>
                        </div>
                      </div>
                    </div>
    
                    <!-- Cardholder Avatar & Name -->
                    <div class="flex items-center gap-2 sm:w-1/5">
                      <ui-avatar size="sm">
                        <ui-avatar-image [src]="card.avatar" [alt]="card.holder" />
                        <ui-avatar-fallback>{{ card.initials }}</ui-avatar-fallback>
                      </ui-avatar>
                      <div class="min-w-0">
                        <p class="text-foreground truncate text-xs font-medium">{{ card.holder }}</p>
                        <p class="text-muted-foreground truncate text-xs">{{ card.type }}</p>
                      </div>
                    </div>
    
                    <!-- Spending Limit & Progress -->
                    <div class="space-y-1.5 sm:w-1/4">
                      <div class="flex items-center justify-between text-xs">
                        <span class="text-foreground font-mono font-medium tabular-nums">
                          \${{ card.spent.toLocaleString() }} / \${{ card.limit.toLocaleString() }}
                        </span>
                        <span class="text-muted-foreground font-mono tabular-nums"> {{ cardPercent(card) }}% </span>
                      </div>
                      <ui-progress [value]="cardPercent(card)" class="h-1.5" />
                    </div>
    
                    <!-- Status Badge & Actions -->
                    <div class="flex items-center justify-between gap-3 sm:justify-end">
                      <span
                        ui-badge
                        wrap
                        [variant]="card.status === 'Active' ? 'success' : 'warning'"
                        class="text-xs whitespace-normal"
                      >
                        {{ card.status }}
                      </span>
    
                      <div class="flex items-center gap-1.5">
                        <button
                          ui-button
                          [variant]="selectedId() === card.id ? 'secondary' : 'ghost'"
                          size="sm"
                          class="text-xs"
                          (click)="selectedId.set(card.id)"
                        >
                          {{ selectedId() === card.id ? 'Viewing' : 'View Card' }}
                        </button>
    
                        <button
                          ui-button
                          variant="ghost"
                          size="icon-sm"
                          [attr.aria-label]="card.status === 'Active' ? 'Freeze card' : 'Unfreeze card'"
                          (click)="toggleFreeze(card.id)"
                        >
                          @if (card.status === 'Active') {
                            <lucide-icon [img]="Snowflake" class="text-muted-foreground size-4" />
                          } @else {
                            <lucide-icon [img]="Zap" class="text-warning size-4" />
                          }
                        </button>
                      </div>
                    </div>
                  </div>
                }
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiVirtualCardManagerComponent {
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Copy = Copy
      protected readonly CreditCard = CreditCard
      protected readonly Eye = Eye
      protected readonly EyeOff = EyeOff
      protected readonly Plus = Plus
      protected readonly Radio = Radio
      protected readonly ShieldCheck = ShieldCheck
      protected readonly SlidersHorizontal = SlidersHorizontal
      protected readonly Snowflake = Snowflake
      protected readonly Trash2 = Trash2
      protected readonly Zap = Zap
    
      @Input('class') className?: string
      @Input() set initialCards(v: VirtualCard[] | undefined) {
        if (v) this.cards.set(v.map((c) => ({ ...c })))
      }
    
      readonly cards = signal<VirtualCard[]>(initialCards.map((c) => ({ ...c })))
      readonly selectedId = signal<string>('vc-1')
      readonly showDetails = signal<boolean>(false)
      readonly copied = signal<boolean>(false)
      readonly actionFeedback = signal<string | null>(null)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
      private feedbackTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly selectedCard = computed(() => this.cards().find((c) => c.id === this.selectedId()) ?? this.cards()[0])
    
      readonly selectedPercent = computed(() => {
        const card = this.selectedCard()
        if (!card || card.limit === 0) return 0
        return Math.min(100, Math.round((card.spent / card.limit) * 100))
      })
    
      readonly availableFunds = computed(() => {
        const card = this.selectedCard()
        return Math.max(0, card.limit - card.spent)
      })
    
      readonly isSelectedFrozen = computed(() => this.selectedCard()?.status === 'Frozen')
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-6', this.className)
      }
    
      get cardVisualClass(): string {
        return cn(
          'relative aspect-[1.586/1] w-full max-w-[420px] rounded-2xl p-6 text-white transition-[filter] duration-300 lg:max-w-full',
          'border border-zinc-700/60 bg-gradient-to-br from-zinc-900 via-neutral-900 to-zinc-950 shadow-xl',
          this.isSelectedFrozen() && 'contrast-95 grayscale filter',
        )
      }
    
      rowClass(card: VirtualCard): string {
        return cn(
          'group flex flex-col gap-4 p-4 transition-colors sm:flex-row sm:items-center sm:justify-between',
          this.selectedId() === card.id ? 'bg-muted/40' : 'hover:bg-muted/20',
        )
      }
    
      cardIconClass(card: VirtualCard): string {
        return cn(
          'border-border flex size-10 shrink-0 items-center justify-center rounded-lg border shadow-xs transition-transform',
          card.status === 'Frozen' ? 'bg-muted/60 text-muted-foreground' : 'bg-primary/10 text-primary',
        )
      }
    
      cardPercent(card: VirtualCard): number {
        if (card.limit === 0) return 0
        return Math.min(100, Math.round((card.spent / card.limit) * 100))
      }
    
      toggleFreeze(id: string): void {
        this.cards.update((prev) =>
          prev.map((c) => {
            if (c.id === id) {
              const nextStatus = c.status === 'Active' ? 'Frozen' : 'Active'
              return { ...c, status: nextStatus }
            }
            return c
          }),
        )
      }
    
      handleCopyNumber(): void {
        const card = this.selectedCard()
        if (!card) return
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(card.fullNumber.replace(/\s+/g, ''))
        }
        this.copied.set(true)
        clearTimeout(this.copyTimer)
        this.copyTimer = setTimeout(() => this.copied.set(false), 2000)
      }
    
      adjustLimit(cardId: string): void {
        const current = this.cards().find((c) => c.id === cardId)
        const newLimit = !current
          ? 0
          : current.limit === 5000
            ? 7500
            : current.limit === 10000
              ? 12000
              : current.limit + 1000
        this.cards.update((prev) =>
          prev.map((c) => {
            if (c.id === cardId) {
              const next = c.limit === 5000 ? 7500 : c.limit === 10000 ? 12000 : c.limit + 1000
              return { ...c, limit: next }
            }
            return c
          }),
        )
        this.showFeedback(`Limit updated to $${newLimit.toLocaleString()}`)
      }
    
      terminateCard(cardId: string): void {
        if (this.cards().length <= 1) {
          this.showFeedback('Cannot terminate the last remaining card')
          return
        }
        const remaining = this.cards().filter((c) => c.id !== cardId)
        this.cards.set(remaining)
        if (this.selectedId() === cardId) {
          this.selectedId.set(remaining[0].id)
        }
        this.showFeedback('Card terminated permanently')
      }
    
      issueNewCard(): void {
        const newId = `vc-${Date.now().toString().slice(-4)}`
        const newLast4 = Math.floor(1000 + Math.random() * 9000).toString()
        const newCard: VirtualCard = {
          id: newId,
          name: 'AI & API Workloads',
          brand: 'visa',
          last4: newLast4,
          fullNumber: `4400 1284 9912 ${newLast4}`,
          holder: 'Elena Rostova',
          avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
          initials: 'ER',
          expiry: '09/30',
          cvv: '814',
          spent: 0,
          limit: 3000,
          type: 'Monthly Recurring',
          status: 'Active',
          billingCycle: 'Resets on 1st of month',
          purpose: 'OpenAI, Anthropic, and Vector DB infrastructure',
        }
        this.cards.update((prev) => [newCard, ...prev])
        this.selectedId.set(newId)
        this.showFeedback('New virtual card issued successfully')
      }
    
      showFeedback(msg: string): void {
        this.actionFeedback.set(msg)
        clearTimeout(this.feedbackTimer)
        this.feedbackTimer = setTimeout(() => {
          if (this.actionFeedback() === msg) this.actionFeedback.set(null)
        }, 3000)
      }
    }
    
  • src/app/components/blocks/virtual-card-manager/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/virtual-card-manager.json