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-vue@latest add https://uipkge.dev/r/vue/saved-cards-list.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/saved-cards-list.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/saved-cards-list.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/saved-cards-list.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/saved-cards-listInstalls to:app/components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
title | string | — | optional |
description | string | — | optional |
onAdd | (payload: AddPayload) | — | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
AddPayloadinterface AddPayload {
number: string
name: string
expiry: string
cvc: string
brand: CardBrand
}npm dependencies
Files installed (3)
app/components/blocks/SavedCardsList.vue2 kB
<script setup lang="ts"> import { computed, ref, useSlots } from 'vue' import type { HTMLAttributes } from 'vue' import { Plus } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import AddCardDialog, { type AddPayload } from './AddCardDialog.vue' const props = defineProps<{ title?: string description?: string onAdd?: (payload: AddPayload) => void class?: HTMLAttributes['class'] }>() const emit = defineEmits<{ add: [payload: AddPayload] }>() const slots = useSlots() const showAdd = ref(false) const hasRows = computed(() => !!slots.default) function handleAdd(p: AddPayload) { props.onAdd?.(p) emit('add', p) } </script> <template> <div data-slot="saved-cards-list" :class="['bg-card text-card-foreground mx-auto w-full max-w-2xl rounded-xl border shadow-sm', props.class]" > <div class="flex items-center justify-between border-b px-5 py-4"> <div> <h3 class="text-base font-semibold">{{ title ?? 'Saved cards' }}</h3> <p class="text-muted-foreground text-xs">{{ description ?? 'Manage the cards used for billing.' }}</p> </div> <slot v-if="$slots['header-action']" name="header-action" /> <Button v-else-if="onAdd" size="sm" @click="showAdd = !showAdd"><Plus class="size-4" /> Add card</Button> </div> <!-- Add form (collapses inline) --> <AddCardDialog v-model:open="showAdd" @add="handleAdd" /> <!-- Empty state --> <div v-if="!hasRows && !showAdd" class="px-6 py-14 text-center"> <div class="bg-muted text-muted-foreground mx-auto grid size-12 place-items-center rounded-full"> <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 v-if="onAdd" size="sm" class="mt-4" @click="showAdd = true">Add your first card</Button> </div> <!-- List --> <ul v-else class="divide-border divide-y"> <slot /> </ul> </div> </template>app/components/blocks/SavedCardRow.vue2.7 kB
<script setup lang="ts"> import { computed, ref } from 'vue' import { CheckCircle2, Trash2 } from 'lucide-vue-next' 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 type { CardBrand } from './AddCardDialog.vue' defineProps<{ brand: CardBrand last4: string expiry: string holder: string isDefault?: boolean }>() const emit = defineEmits<{ 'set-default': []; remove: [] }>() const removeTarget = ref(false) 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}` } const confirmOpen = computed({ get: () => removeTarget.value, set: (v) => { if (!v) removeTarget.value = false }, }) function confirmRemove() { emit('remove') removeTarget.value = false } </script> <template> <li class="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 class="min-w-0 flex-1"> <div class="flex items-center gap-2"> <p class="text-sm font-medium">{{ brandLabel(brand) }} ending {{ last4 }}</p> <Badge v-if="isDefault" variant="secondary"><CheckCircle2 class="size-3" /> Default</Badge> </div> <p class="text-muted-foreground text-xs">Expires {{ expiry }} · {{ holder }}</p> </div> <div class="flex gap-1"> <Button v-if="!isDefault" variant="ghost" size="sm" @click="emit('set-default')"> Set default </Button> <Button variant="ghost" size="icon-sm" aria-label="Remove card" @click="removeTarget = true"> <Trash2 class="text-muted-foreground size-4" /> </Button> </div> </li> <!-- Remove confirmation --> <Dialog v-model:open="confirmOpen"> <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" @click="removeTarget = false">Cancel</Button> <Button variant="destructive" @click="confirmRemove">Remove</Button> </DialogFooter> </DialogContent> </Dialog> </template>app/components/blocks/AddCardDialog.vue0.7 kB
<script setup lang="ts"> import PaymentForm from '@/components/blocks/PaymentForm.vue' export type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown' export interface AddPayload { number: string name: string expiry: string cvc: string brand: CardBrand } defineProps<{ open: boolean }>() const emit = defineEmits<{ 'update:open': [value: boolean]; add: [payload: AddPayload] }>() async function handleSubmit(p: AddPayload) { emit('add', p) emit('update:open', false) } </script> <template> <div v-if="open" class="bg-muted/30 max-h-[1000px] overflow-hidden border-b p-4 opacity-100"> <PaymentForm :amount="0" :show-wallets="false" :on-submit="handleSubmit" /> </div> </template>
Raw manifest:https://uipkge.dev/r/vue/saved-cards-list.json