Menu

Change language

GitHub repo

Saved Cards List

blockfinance

List of stored payment cards using compact 3D card visuals. Marks one as the default, allows setting a different one as default, removing with a confirmation dialog, and adding a new card via an inline PaymentForm that collapses open. Emits `add`, `remove`, and `set-default` — consumer owns the persistence.

Installation

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

Variants

Loading interactive previews…

Schema

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

SavedCard
interface SavedCard {
  id: string
  brand: CardBrand
  last4: string
  expiry: string
  holder: string
}
AddPayload
interface AddPayload {
  number: string
  name: string
  expiry: string
  cvc: string
  brand: CardBrand
}

Files installed (2)

  • src/app/components/blocks/saved-cards-list/saved-cards-list.component.ts9.3 kB
    // Saved cards list: stored payment cards with compact card visuals, a default badge,
    // set-default / remove-with-confirm actions, and an inline PaymentForm that collapses
    // open. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'
    import { CheckCircle2, LucideAngularModule, Plus, Trash2 } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import { UiPaymentCardComponent } from '@/ui/payment-card/payment-card.component'
    import {
      UiPaymentFormComponent,
      type PaymentFormCardBrand,
      type PaymentPayload,
    } from '../payment-form/payment-form.component'
    
    export type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'
    
    export interface SavedCard {
      id: string
      brand: CardBrand
      last4: string
      expiry: string
      holder: string
    }
    
    export interface AddPayload {
      number: string
      name: string
      expiry: string
      cvc: string
      brand: CardBrand
    }
    
    const defaultCards: SavedCard[] = [
      { id: 'c1', brand: 'visa', last4: '4242', expiry: '12/29', holder: 'Jane Doe' },
      { id: 'c2', brand: 'mastercard', last4: '4444', expiry: '08/27', holder: 'Jane Doe' },
      { id: 'c3', brand: 'amex', last4: '0005', expiry: '03/30', holder: 'Jane Doe' },
    ]
    
    function brandLabel(b: CardBrand): string {
      return { visa: 'Visa', mastercard: 'Mastercard', amex: 'Amex', discover: 'Discover', unknown: 'Card' }[b]
    }
    
    function maskedNumberFromLast4(last4: string, brand: CardBrand): string {
      if (brand === 'amex') return `•••• •••••• •${last4}`
      return `•••• •••• •••• ${last4}`
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-saved-cards-list, [ui-saved-cards-list]',
      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,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiPaymentCardComponent,
        UiPaymentFormComponent,
      ],
      template: `
        <div data-slot="saved-cards-list" [class]="rootClass">
          <div class="flex items-center justify-between border-b px-5 py-4">
            <div>
              <h3 class="text-base font-semibold">{{ title() }}</h3>
              <p class="text-muted-foreground text-xs">{{ description() }}</p>
            </div>
            <button ui-button size="sm" (click)="toggleAdd()">
              <lucide-icon [img]="Plus" class="size-4" />
              <span>Add card</span>
            </button>
          </div>
    
          <!-- Add form (collapses inline) -->
          @if (showAdd()) {
            <div class="bg-muted/30 max-h-[1000px] overflow-hidden border-b p-4 opacity-100">
              <ui-payment-form [amount]="0" [showWallets]="false" [onSubmit]="submitAdd" />
            </div>
          }
    
          <!-- Empty state -->
          @if (!hasRows() && !showAdd()) {
            <div class="px-6 py-14 text-center">
              <div class="bg-muted text-muted-foreground mx-auto grid size-12 place-items-center rounded-full">
                <lucide-icon [img]="Plus" class="size-5" />
              </div>
              <h4 class="mt-3 text-sm font-medium">No cards saved</h4>
              <p class="text-muted-foreground text-xs">Add a card to use it for future payments.</p>
              <button ui-button size="sm" class="mt-4" (click)="showAdd.set(true)">Add your first card</button>
            </div>
          } @else {
            <ul class="divide-border divide-y">
              @for (card of cards(); track card.id) {
                <li class="hover:bg-muted/30 flex items-center gap-4 px-5 py-4 transition-colors">
                  <ui-payment-card
                    [number]="masked(card.last4, card.brand)"
                    [name]="card.holder"
                    [expiry]="card.expiry"
                    [brand]="card.brand"
                    variant="compact"
                    [flip]="false"
                  />
                  <div class="min-w-0 flex-1">
                    <div class="flex items-center gap-2">
                      <p class="text-sm font-medium">{{ label(card.brand) }} ending {{ card.last4 }}</p>
                      @if (card.id === defaultId()) {
                        <span ui-badge variant="secondary">
                          <lucide-icon [img]="CheckCircle2" class="size-3" />
                          <span>Default</span>
                        </span>
                      }
                    </div>
                    <p class="text-muted-foreground text-xs">Expires {{ card.expiry }} · {{ card.holder }}</p>
                  </div>
                  <div class="flex gap-1">
                    @if (card.id !== defaultId()) {
                      <button ui-button variant="ghost" size="sm" (click)="setDefault(card.id)">Set default</button>
                    }
                    <button
                      ui-button
                      variant="ghost"
                      size="icon-sm"
                      aria-label="Remove card"
                      (click)="confirmId.set(card.id)"
                    >
                      <lucide-icon [img]="Trash2" class="text-muted-foreground size-4" />
                    </button>
                  </div>
                </li>
              }
            </ul>
          }
    
          <!-- Remove confirmation -->
          <ui-dialog [open]="confirmCard() !== null" (openChange)="onConfirmOpenChange($event)">
            @if (confirmCard(); as target) {
              <ui-dialog-content>
                <div ui-dialog-header>
                  <h2 ui-dialog-title>Remove this card?</h2>
                  <p ui-dialog-description>
                    {{ label(target.brand) }} ending {{ target.last4 }} will no longer be available for payments.
                  </p>
                </div>
                <div ui-dialog-footer>
                  <button ui-button variant="outline" (click)="confirmId.set(null)">Cancel</button>
                  <button ui-button variant="destructive" (click)="confirmRemove()">Remove</button>
                </div>
              </ui-dialog-content>
            }
          </ui-dialog>
        </div>
      `,
    })
    export class UiSavedCardsListComponent {
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Plus = Plus
      protected readonly Trash2 = Trash2
    
      @Input('title') set titleInput(v: string | undefined) {
        this.title.set(v ?? 'Saved cards')
      }
      @Input('description') set descriptionInput(v: string | undefined) {
        this.description.set(v ?? 'Manage the cards used for billing.')
      }
      @Input() set initialCards(v: SavedCard[] | undefined) {
        this.cards.set((v ?? defaultCards).map((c) => ({ ...c })))
        if (!this.defaultId()) this.defaultId.set((v ?? defaultCards)[0]?.id ?? '')
      }
      @Input() set initialDefaultId(v: string | undefined) {
        if (v) this.defaultId.set(v)
      }
      @Input() set initialShowAdd(v: boolean) {
        this.showAdd.set(!!v)
      }
      @Input('class') className?: string
    
      @Output() readonly add = new EventEmitter<AddPayload>()
      @Output() readonly remove = new EventEmitter<string>()
      @Output() readonly setDefaultChange = new EventEmitter<string>()
    
      readonly title = signal('Saved cards')
      readonly description = signal('Manage the cards used for billing.')
      readonly cards = signal<SavedCard[]>(defaultCards.map((c) => ({ ...c })))
      readonly defaultId = signal<string>(defaultCards[0]?.id ?? '')
      readonly showAdd = signal(false)
      readonly confirmId = signal<string | null>(null)
    
      readonly hasRows = computed(() => this.cards().length > 0)
      readonly confirmCard = computed(() => this.cards().find((c) => c.id === this.confirmId()) ?? null)
    
      get rootClass(): string {
        return cn('bg-card text-card-foreground mx-auto w-full max-w-2xl rounded-xl border shadow-sm', this.className)
      }
    
      label(b: CardBrand): string {
        return brandLabel(b)
      }
    
      masked(last4: string, brand: CardBrand): string {
        return maskedNumberFromLast4(last4, brand)
      }
    
      toggleAdd(): void {
        this.showAdd.update((v) => !v)
      }
    
      readonly submitAdd = async (p: PaymentPayload): Promise<void> => {
        this.handleAdd({
          number: p.number,
          name: p.name,
          expiry: p.expiry,
          cvc: p.cvc,
          brand: p.brand as CardBrand,
        })
      }
    
      handleAdd(payload: AddPayload): void {
        const card: SavedCard = {
          id: `c${Date.now()}`,
          brand: payload.brand as CardBrand,
          last4: payload.number.replace(/\D/g, '').slice(-4),
          expiry: payload.expiry,
          holder: payload.name,
        }
        this.cards.update((prev) => [...prev, card])
        if (!this.defaultId()) this.defaultId.set(card.id)
        this.showAdd.set(false)
        this.add.emit(payload)
      }
    
      setDefault(id: string): void {
        this.defaultId.set(id)
        this.setDefaultChange.emit(id)
      }
    
      confirmRemove(): void {
        const id = this.confirmId()
        if (!id) return
        this.cards.update((prev) => {
          const next = prev.filter((c) => c.id !== id)
          if (this.defaultId() === id) this.defaultId.set(next[0]?.id ?? '')
          return next
        })
        this.confirmId.set(null)
        this.remove.emit(id)
      }
    
      onConfirmOpenChange(open: boolean): void {
        if (!open) this.confirmId.set(null)
      }
    }
    
    export type { PaymentFormCardBrand }
    
  • src/app/components/blocks/saved-cards-list/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/saved-cards-list.json