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 shadcn@latest add https://uipkge.dev/r/react/saved-cards-list.json$npx shadcn@latest add https://uipkge.dev/r/react/saved-cards-list.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/saved-cards-list.json$bunx shadcn@latest add https://uipkge.dev/r/react/saved-cards-list.jsonNamed registry:
npx shadcn@latest add @uipkge-react/saved-cards-listInstalls to:components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
title | string | — | optional |
description | string | — | optional |
headerAction | React.ReactNode | — | optional |
onAdd | (payload: AddPayload) => void | — | optional |
className | string | — | optional |
children | React.ReactNode | — | optional |
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)
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
'use client' import * as React from 'react' import { PaymentForm } from '@/components/blocks/PaymentForm' export type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown' export interface AddPayload { number: string name: string expiry: string cvc: string brand: CardBrand } export interface AddCardDialogProps { open: boolean onOpenChange?: (open: boolean) => void onAdd?: (payload: AddPayload) => void } export function AddCardDialog({ open, onOpenChange, onAdd }: AddCardDialogProps) { if (!open) return null return ( <div className="bg-muted/30 max-h-[1000px] overflow-hidden border-b p-4 opacity-100"> <PaymentForm amount={0} showWallets={false} onSubmit={async (p) => { onAdd?.(p as AddPayload) onOpenChange?.(false) }} /> </div> ) }
Raw manifest:https://uipkge.dev/r/react/saved-cards-list.json