UIPackage
Menu

Framework

Change language

Boilerplate 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.

Also available for Vue ->

Installation

$npx shadcn@latest add https://uipkge.dev/r/react/saved-cards-list.json
Named registry:npx shadcn@latest add @uipkge-react/saved-cards-listInstalls to:components/blocks/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
titlestringoptional
descriptionstringoptional
headerActionReact.ReactNodeoptional
onAdd(payload: AddPayload) => voidoptional
classNamestringoptional
childrenReact.ReactNodeoptional

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)

  • components/blocks/SavedCardsList.tsx5.4 kB
    'use client'
    
    import * as React from 'react'
    import { CheckCircle2, Plus, Trash2 } from 'lucide-react'
    import { PaymentCard } from '@/components/ui/payment-card'
    import { Button } from '@/components/ui/button'
    import { Badge } from '@/components/ui/badge'
    import {
      Dialog,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogTitle,
    } from '@/components/ui/dialog'
    import { AddCardDialog, type AddPayload, type CardBrand } from './AddCardDialog'
    
    export type { CardBrand, AddPayload } from './AddCardDialog'
    
    export interface SavedCard {
      id: string
      brand: CardBrand
      last4: string
      expiry: string
      holder: string
    }
    
    export interface SavedCardsListProps {
      title?: string
      description?: string
      headerAction?: React.ReactNode
      onAdd?: (payload: AddPayload) => void
      className?: string
      children?: React.ReactNode
    }
    
    export function SavedCardsList({ title, description, headerAction, onAdd, className, children }: SavedCardsListProps) {
      const [showAdd, setShowAdd] = React.useState(false)
    
      const hasRows = React.Children.count(children) > 0
    
      return (
        <div
          data-slot="saved-cards-list"
          className={['bg-card text-card-foreground mx-auto w-full max-w-2xl rounded-xl border shadow-sm', className]
            .filter(Boolean)
            .join(' ')}
        >
          <div className="flex items-center justify-between border-b px-5 py-4">
            <div>
              <h3 className="text-base font-semibold">{title ?? 'Saved cards'}</h3>
              <p className="text-muted-foreground text-xs">{description ?? 'Manage the cards used for billing.'}</p>
            </div>
            {headerAction ??
              (onAdd ? (
                <Button size="sm" onClick={() => setShowAdd((v) => !v)}>
                  <Plus className="size-4" /> Add card
                </Button>
              ) : null)}
          </div>
    
          {/* Add form (collapses inline) */}
          <AddCardDialog open={showAdd} onOpenChange={setShowAdd} onAdd={onAdd} />
    
          {/* Empty state */}
          {!hasRows && !showAdd ? (
            <div className="px-6 py-14 text-center">
              <div className="bg-muted text-muted-foreground mx-auto grid size-12 place-items-center rounded-full">
                <Plus className="size-5" />
              </div>
              <h4 className="mt-3 text-sm font-medium">No cards saved</h4>
              <p className="text-muted-foreground text-xs">Add a card to use it for future payments.</p>
              {onAdd ? (
                <Button size="sm" className="mt-4" onClick={() => setShowAdd(true)}>
                  Add your first card
                </Button>
              ) : null}
            </div>
          ) : (
            /* List */
            <ul className="divide-border divide-y">{children}</ul>
          )}
        </div>
      )
    }
    
    function brandLabel(b: CardBrand) {
      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}`
    }
    
    export interface SavedCardRowProps {
      brand: CardBrand
      last4: string
      expiry: string
      holder: string
      isDefault?: boolean
      onSetDefault?: () => void
      onRemove?: () => void
    }
    
    export function SavedCardRow({ brand, last4, expiry, holder, isDefault, onSetDefault, onRemove }: SavedCardRowProps) {
      const [confirmOpen, setConfirmOpen] = React.useState(false)
    
      function confirmRemove() {
        onRemove?.()
        setConfirmOpen(false)
      }
    
      return (
        <>
          <li className="hover:bg-muted/30 flex items-center gap-4 px-5 py-4 transition-colors">
            <PaymentCard
              number={maskedNumberFromLast4(last4, brand)}
              name={holder}
              expiry={expiry}
              brand={brand}
              variant="compact"
              flip={false}
            />
            <div className="min-w-0 flex-1">
              <div className="flex items-center gap-2">
                <p className="text-sm font-medium">
                  {brandLabel(brand)} ending {last4}
                </p>
                {isDefault ? (
                  <Badge variant="secondary">
                    <CheckCircle2 className="size-3" /> Default
                  </Badge>
                ) : null}
              </div>
              <p className="text-muted-foreground text-xs">
                Expires {expiry} · {holder}
              </p>
            </div>
            <div className="flex gap-1">
              {!isDefault && onSetDefault ? (
                <Button variant="ghost" size="sm" onClick={onSetDefault}>
                  Set default
                </Button>
              ) : null}
              {onRemove ? (
                <Button variant="ghost" size="icon-sm" aria-label="Remove card" onClick={() => setConfirmOpen(true)}>
                  <Trash2 className="text-muted-foreground size-4" />
                </Button>
              ) : null}
            </div>
          </li>
    
          {/* Remove confirmation */}
          <Dialog open={confirmOpen} onOpenChange={(open) => !open && setConfirmOpen(false)}>
            <DialogContent>
              <DialogHeader>
                <DialogTitle>Remove this card?</DialogTitle>
                <DialogDescription>
                  {brandLabel(brand)} ending {last4} will no longer be available for payments.
                </DialogDescription>
              </DialogHeader>
              <DialogFooter>
                <Button variant="outline" onClick={() => setConfirmOpen(false)}>
                  Cancel
                </Button>
                <Button variant="destructive" onClick={confirmRemove}>
                  Remove
                </Button>
              </DialogFooter>
            </DialogContent>
          </Dialog>
        </>
      )
    }
    
  • components/blocks/AddCardDialog.tsx0.8 kB

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