Saved Cards List
blockfinanceList 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
$pnpm dlx uipkge-ng@latest add saved-cards-list$npx uipkge-ng@latest add saved-cards-list$yarn dlx uipkge-ng@latest add saved-cards-list$bunx uipkge-ng@latest add saved-cards-listManifest:
https://uipkge.dev/r/angular/saved-cards-list.jsonInstalls to:src/app/components/blocks/saved-cards-list/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
SavedCardinterface SavedCard {
id: string
brand: CardBrand
last4: string
expiry: string
holder: string
}AddPayloadinterface AddPayload {
number: string
name: string
expiry: string
cvc: string
brand: CardBrand
}npm dependencies
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
export { UiSavedCardsListComponent, type CardBrand, type SavedCard, type AddPayload, } from './saved-cards-list.component'
Raw manifest:https://uipkge.dev/r/angular/saved-cards-list.json