
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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 React ->$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.jsonnpx shadcn-vue@latest add @uipkge/saved-cards-listInstalls to:app/components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
title | string | — | optional |
description | string | — | optional |
onAdd | (payload: AddPayload) | — | optional |
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
}<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>
<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>
<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