Property Listing Card
blockreal-estateZillow and Redfin style real estate property listing card featuring interactive photo carousels with 16:9 aspect ratios, price and mortgage estimation, specification pills (beds, baths, square footage, price/sqft), address, key feature tags, listing agent attribution, and direct tour scheduling actions.
Installation
$pnpm dlx uipkge-ng@latest add property-listing-card$npx uipkge-ng@latest add property-listing-card$yarn dlx uipkge-ng@latest add property-listing-card$bunx uipkge-ng@latest add property-listing-cardManifest:
https://uipkge.dev/r/angular/property-listing-card.jsonInstalls to:src/app/components/blocks/property-listing-card/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.
PropertyIteminterface PropertyItem {
id: string
title: string
propertyType: string
category: 'all' | 'single-family' | 'waterfront' | 'penthouse'
price: number
mortgageEst: string
address: { street: string; cityStateZip: string; neighborhood: string }
specs: { beds: number; baths: number; sqft: number; pricePerSqft: number; yearBuilt: number; lotSize: string }
badge: { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }
hasVirtualTour: boolean
openHouse: string
totalPhotos: number
images: string[]
tags: string[]
agent: { name: string; brokerage: string; avatar: string; initials: string; badge: string }
}Files installed (2)
src/app/components/blocks/property-listing-card/property-listing-card.component.ts26.4 kB
// Zillow and Redfin style real estate property listing card with interactive photo // carousels, price and mortgage estimates, spec pills, tags, agent attribution, and tour // scheduling actions. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { BadgeCheck, Bath, BedDouble, Box, Calculator, Calendar, Camera, Check, ChevronLeft, ChevronRight, Compass, Heart, LucideAngularModule, Mail, MapPin, Maximize2, Share2, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' export interface PropertyItem { id: string title: string propertyType: string category: 'all' | 'single-family' | 'waterfront' | 'penthouse' price: number mortgageEst: string address: { street: string; cityStateZip: string; neighborhood: string } specs: { beds: number; baths: number; sqft: number; pricePerSqft: number; yearBuilt: number; lotSize: string } badge: { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' } hasVirtualTour: boolean openHouse: string totalPhotos: number images: string[] tags: string[] agent: { name: string; brokerage: string; avatar: string; initials: string; badge: string } } export const PROPERTIES: PropertyItem[] = [ { id: 'prop-pasadena', title: 'Mid-Century Modern Architectural Sanctuary', propertyType: 'Single Family Residence', category: 'single-family', price: 1245000, mortgageEst: 'Est. $6,420 / mo', address: { street: '742 Evergreen Terrace', cityStateZip: 'Pasadena, CA 91101', neighborhood: 'San Rafael Hills', }, specs: { beds: 4, baths: 3.5, sqft: 2850, pricePerSqft: 437, yearBuilt: 1962, lotSize: '0.34 Acres' }, badge: { label: 'New Listing', variant: 'default' }, hasVirtualTour: true, openHouse: 'Open Sun 1:00 – 4:00 PM', totalPhotos: 18, images: [ 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=1200&auto=format&fit=crop&q=80', ], tags: ['Swimming Pool', 'Solar Panels', '2-Car Garage', 'Modern Kitchen', 'Mountain View'], agent: { name: 'Elena Rostova', brokerage: "Sotheby's International Realty", avatar: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=200&auto=format&fit=crop&q=80', initials: 'ER', badge: 'Top 1% Producer', }, }, { id: 'prop-carmel', title: 'Coastal Craftsman Villa with Ocean Vista', propertyType: 'Luxury Waterfront Estate', category: 'waterfront', price: 2890000, mortgageEst: 'Est. $14,850 / mo', address: { street: '1840 Ocean View Avenue', cityStateZip: 'Carmel-by-the-Sea, CA 93921', neighborhood: 'Point Lobos Highlands', }, specs: { beds: 5, baths: 4.5, sqft: 4120, pricePerSqft: 701, yearBuilt: 2021, lotSize: '0.52 Acres' }, badge: { label: 'Price Drop $100k', variant: 'secondary' }, hasVirtualTour: true, openHouse: 'Private Viewings Only', totalPhotos: 24, images: [ 'https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1600585152220-90363fe7e115?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1584622650111-993a426fbf0a?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1600573472592-401b489a3cdc?w=1200&auto=format&fit=crop&q=80', ], tags: ['Ocean Frontage', 'Wine Cellar', 'Infinity Pool', 'Smart Automation', 'Chef Kitchen'], agent: { name: 'Marcus Vance', brokerage: 'Compass Premier Collection', avatar: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?w=200&auto=format&fit=crop&q=80', initials: 'MV', badge: 'Luxury Director', }, }, { id: 'prop-downtown', title: 'Skyline Glass Penthouse & Private Terrace', propertyType: 'High-Rise Penthouse Condo', category: 'penthouse', price: 895000, mortgageEst: 'Est. $4,680 / mo', address: { street: '450 S Grand Avenue, Unit 38B', cityStateZip: 'Los Angeles, CA 90071', neighborhood: 'Bunker Hill Downtown', }, specs: { beds: 2, baths: 2.0, sqft: 1640, pricePerSqft: 545, yearBuilt: 2019, lotSize: 'HOA $620 / mo' }, badge: { label: 'Just Listed', variant: 'default' }, hasVirtualTour: true, openHouse: 'Open Sat 2:00 – 5:00 PM', totalPhotos: 14, images: [ 'https://images.unsplash.com/photo-1545324418-cc1a3fa10c00?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1556911220-e15b29be8c8f?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1513694203232-719a280e022f?w=1200&auto=format&fit=crop&q=80', 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=1200&auto=format&fit=crop&q=80', ], tags: ['Private Terrace', '24/7 Concierge', 'Floor-to-Ceiling Windows', 'EV Parking', 'Fitness Center'], agent: { name: 'Aria Chen', brokerage: 'Coldwell Banker Global Luxury', avatar: 'https://images.unsplash.com/photo-1580489944761-15a19d654956?w=200&auto=format&fit=crop&q=80', initials: 'AC', badge: 'Downtown Specialist', }, }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-property-listing-card, [ui-property-listing-card]', standalone: true, host: { '[attr.data-slot]': '"property-listing-card"', '[class]': 'hostClass', }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiSeparatorComponent, ], template: ` <!-- Header & Category Switcher --> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex items-center gap-2.5"> <h2 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">Featured Property Listings</h2> <span ui-badge variant="secondary" class="text-xs font-normal tabular-nums"> {{ filteredProperties().length }} Active </span> </div> <p class="text-muted-foreground text-xs sm:text-sm"> Browse verified real estate properties with interactive photo galleries, mortgage calculations, and 3D walkthroughs. </p> </div> <!-- Category Filter Pills --> <div class="border-border/80 bg-muted/40 flex flex-wrap items-center gap-1.5 rounded-lg border p-1"> @for (f of categoryFilters; track f.value) { <button type="button" [class]="filterPillClass(f.value)" (click)="activeCategoryFilter.set(f.value)"> {{ f.label }} </button> } </div> </div> <!-- Properties Grid --> <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> @for (prop of filteredProperties(); track prop.id) { <div ui-card class="group border-border bg-card hover:border-border/80 relative flex flex-col justify-between overflow-hidden rounded-xl border shadow-xs transition-colors duration-300 hover:shadow-md" > <!-- 1. Image Carousel Gallery Hero (16:9 Aspect Ratio) --> <div class="bg-muted relative aspect-video w-full overflow-hidden"> <img [src]="prop.images[getPhotoIndex(prop.id)]" [alt]="prop.title + ' - Photo ' + (getPhotoIndex(prop.id) + 1)" class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover:scale-105" loading="lazy" /> <div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/15 to-black/60" ></div> <!-- Top Overlay: Badges and Wishlist Button --> <div class="absolute inset-x-3 top-3 z-10 flex items-start justify-between gap-2"> <div class="flex flex-wrap items-center gap-1.5"> <span ui-badge [variant]="prop.badge.variant" class="text-xs font-semibold shadow-xs"> {{ prop.badge.label }} </span> @if (prop.hasVirtualTour) { <button type="button" class="inline-flex min-h-6 items-center gap-1 rounded-full border border-white/25 bg-black/60 px-2.5 py-0.5 text-xs font-medium text-white shadow-xs backdrop-blur-md transition-colors hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none" [title]="activeTourModalId() === prop.id ? 'Exit 3D Tour Mode' : 'Explore 3D Virtual Tour'" (click)="toggleVirtualTour(prop.id); $event.stopPropagation()" > <lucide-icon [img]="Box" class="text-success size-3" /> <span>Virtual Tour 3D</span> </button> } </div> <button type="button" aria-label="Save property to wishlist" class="flex size-8 shrink-0 items-center justify-center rounded-full border border-white/20 bg-black/50 text-white shadow-xs backdrop-blur-md transition-colors hover:bg-black/75 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none" [class]="isWishlisted(prop.id) ? 'text-destructive hover:text-destructive' : 'text-white'" (click)="toggleWishlist(prop.id); $event.stopPropagation()" > <lucide-icon [img]="Heart" class="size-4" [class]="isWishlisted(prop.id) ? 'fill-destructive text-destructive' : ''" /> </button> </div> <!-- Chevron Carousel Controls --> <button type="button" aria-label="Previous property photo" class="absolute top-1/2 left-2.5 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full border border-white/20 bg-black/55 text-white opacity-0 shadow-xs backdrop-blur-md transition-colors group-hover:opacity-100 hover:bg-black/80 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none" (click)="prevPhoto(prop.id, prop.images.length); $event.stopPropagation()" > <lucide-icon [img]="ChevronLeft" class="size-4" /> </button> <button type="button" aria-label="Next property photo" class="absolute top-1/2 right-2.5 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full border border-white/20 bg-black/55 text-white opacity-0 shadow-xs backdrop-blur-md transition-colors group-hover:opacity-100 hover:bg-black/80 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none" (click)="nextPhoto(prop.id, prop.images.length); $event.stopPropagation()" > <lucide-icon [img]="ChevronRight" class="size-4" /> </button> <!-- Bottom Hero Overlay --> <div class="absolute inset-x-3 bottom-3 z-10 flex items-center justify-between gap-2"> <span class="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-2.5 py-0.5 text-xs font-medium text-white tabular-nums backdrop-blur-md" > <lucide-icon [img]="Camera" class="size-3 text-white/80" /> {{ getPhotoIndex(prop.id) + 1 }} / {{ prop.totalPhotos }} Photos </span> <div class="flex items-center gap-1 rounded-full bg-black/40 px-2 py-1 backdrop-blur-xs"> @for (img of prop.images; track $index) { <button type="button" [attr.aria-label]="'Jump to photo ' + ($index + 1)" class="rounded-full transition-colors focus-visible:ring-1 focus-visible:ring-white focus-visible:outline-none" [class]=" getPhotoIndex(prop.id) === $index ? 'h-1.5 w-3.5 bg-white' : 'size-1.5 bg-white/50 hover:bg-white/80' " (click)="setPhoto(prop.id, $index); $event.stopPropagation()" ></button> } </div> </div> @if (activeTourModalId() === prop.id) { <div class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/85 p-4 text-center backdrop-blur-sm transition-colors" > <lucide-icon [img]="Compass" class="text-success size-8 animate-spin" /> <h4 class="mt-2 text-sm font-semibold text-white">3D Virtual Space Initialized</h4> <p class="mt-1 max-w-xs text-xs text-white/80"> Interactive 360° LiDAR spatial model active for {{ prop.address.street }}. </p> <button ui-button size="sm" variant="secondary" class="mt-3 text-xs" (click)="activeTourModalId.set(null); $event.stopPropagation()" > Close 3D View </button> </div> } </div> <!-- 2. Card Content & Specifications --> <div ui-card-content class="flex flex-1 flex-col justify-between space-y-4 p-5"> <div class="space-y-3.5"> <div class="flex flex-wrap items-start justify-between gap-3"> <div class="space-y-1"> <div class="flex items-baseline gap-2"> <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums"> {{ formatPrice(prop.price) }} </span> </div> <div class="text-muted-foreground flex items-center gap-1.5 text-xs font-normal tabular-nums"> <lucide-icon [img]="Calculator" class="text-muted-foreground/80 size-3.5 shrink-0" /> <span>{{ prop.mortgageEst }}</span> </div> </div> <span ui-badge variant="secondary" class="shrink-0 text-xs font-normal"> {{ prop.propertyType }} </span> </div> <div class="grid grid-cols-4 gap-1.5 py-0.5"> <div class="border-border/60 bg-muted/40 flex flex-col items-center justify-center rounded-lg border p-2 text-center" > <div class="text-muted-foreground flex items-center gap-1"> <lucide-icon [img]="BedDouble" class="size-3.5" /> <span class="text-foreground text-xs font-semibold tabular-nums">{{ prop.specs.beds }}</span> </div> <span class="text-muted-foreground text-xs">Beds</span> </div> <div class="border-border/60 bg-muted/40 flex flex-col items-center justify-center rounded-lg border p-2 text-center" > <div class="text-muted-foreground flex items-center gap-1"> <lucide-icon [img]="Bath" class="size-3.5" /> <span class="text-foreground text-xs font-semibold tabular-nums">{{ prop.specs.baths }}</span> </div> <span class="text-muted-foreground text-xs">Baths</span> </div> <div class="border-border/60 bg-muted/40 flex flex-col items-center justify-center rounded-lg border p-2 text-center" > <div class="text-muted-foreground flex items-center gap-1"> <lucide-icon [img]="Maximize2" class="size-3.5" /> <span class="text-foreground text-xs font-semibold tabular-nums"> {{ prop.specs.sqft.toLocaleString() }} </span> </div> <span class="text-muted-foreground text-xs">Sq Ft</span> </div> <div class="border-border/60 bg-muted/40 flex flex-col items-center justify-center rounded-lg border p-2 text-center" > <div class="text-muted-foreground flex items-center gap-0.5"> <span class="text-foreground text-xs font-semibold tabular-nums" >\${{ prop.specs.pricePerSqft }}</span > </div> <span class="text-muted-foreground text-xs">/ sqft</span> </div> </div> <div class="space-y-1"> <h3 class="text-foreground hover:text-primary line-clamp-1 cursor-pointer text-sm font-semibold tracking-tight transition-colors" [title]="prop.address.street" > {{ prop.address.street }} </h3> <p class="text-muted-foreground flex items-center gap-1.5 text-xs"> <lucide-icon [img]="MapPin" class="text-muted-foreground/80 size-3.5 shrink-0" /> <span class="truncate">{{ prop.address.cityStateZip }}</span> <span>·</span> <span class="text-foreground/85 shrink-0 font-medium">{{ prop.address.neighborhood }}</span> </p> </div> <div class="flex flex-wrap gap-1.5 pt-0.5"> @for (tag of prop.tags; track tag) { <span ui-badge variant="outline" class="border-border/70 bg-muted/30 text-muted-foreground hover:bg-muted/60 text-xs font-normal transition-colors" > {{ tag }} </span> } </div> </div> <div class="space-y-3.5 pt-2"> <ui-separator /> <div class="flex items-center justify-between gap-3"> <div class="flex min-w-0 items-center gap-2.5"> <ui-avatar size="sm" class="border-border size-8 border shadow-2xs"> <ui-avatar-image [src]="prop.agent.avatar" [alt]="prop.agent.name" /> <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-semibold"> {{ prop.agent.initials }} </ui-avatar-fallback> </ui-avatar> <div class="min-w-0"> <p class="text-foreground flex items-center gap-1 truncate text-xs font-semibold"> <span>Listed by {{ prop.agent.name }}</span> <lucide-icon [img]="BadgeCheck" class="text-primary size-3 shrink-0" /> </p> <p class="text-muted-foreground truncate text-xs">{{ prop.agent.brokerage }}</p> </div> </div> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8 shrink-0 rounded-full" [title]="copiedLinkId() === prop.id ? 'Listing link copied!' : 'Share listing'" (click)="shareListing(prop.id)" > @if (copiedLinkId() === prop.id) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Share2" class="size-3.5" /> } <span class="sr-only">Share listing</span> </button> </div> <div class="grid grid-cols-2 gap-2"> <button ui-button size="sm" class="w-full gap-1.5 text-xs font-medium shadow-xs" [variant]="isTourScheduled(prop.id) ? 'secondary' : 'default'" (click)="toggleScheduleTour(prop.id)" > @if (isTourScheduled(prop.id)) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Calendar" class="size-3.5" /> } <span>{{ isTourScheduled(prop.id) ? 'Tour Booked ✓' : 'Schedule Tour' }}</span> </button> <button ui-button variant="outline" size="sm" class="w-full gap-1.5 text-xs font-medium" [class]="isAgentContacted(prop.id) ? 'border-success/50 bg-success/10 text-success' : ''" (click)="toggleContactAgent(prop.id)" > @if (isAgentContacted(prop.id)) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Mail" class="size-3.5" /> } <span>{{ isAgentContacted(prop.id) ? 'Message Sent' : 'Contact Agent' }}</span> </button> </div> </div> </div> </div> } </div> `, }) export class UiPropertyListingCardComponent { protected readonly BadgeCheck = BadgeCheck protected readonly Bath = Bath protected readonly BedDouble = BedDouble protected readonly Box = Box protected readonly Calculator = Calculator protected readonly Calendar = Calendar protected readonly Camera = Camera protected readonly Check = Check protected readonly ChevronLeft = ChevronLeft protected readonly ChevronRight = ChevronRight protected readonly Compass = Compass protected readonly Heart = Heart protected readonly Mail = Mail protected readonly MapPin = MapPin protected readonly Maximize2 = Maximize2 protected readonly Share2 = Share2 @Input('class') className?: string @Input() set initialCategory(v: string) { this.activeCategoryFilter.set(v ?? 'all') } protected readonly categoryFilters = [ { value: 'all', label: 'All Homes' }, { value: 'single-family', label: 'Single Family' }, { value: 'waterfront', label: 'Waterfront' }, { value: 'penthouse', label: 'Penthouse' }, ] readonly activePhotoIndex = signal<Record<string, number>>({ 'prop-pasadena': 0, 'prop-carmel': 0, 'prop-downtown': 0, }) readonly wishlistedIds = signal<Set<string>>(new Set(['prop-pasadena'])) readonly scheduledTourIds = signal<Set<string>>(new Set()) readonly contactedAgentIds = signal<Set<string>>(new Set()) readonly copiedLinkId = signal<string | null>(null) readonly activeCategoryFilter = signal<string>('all') readonly activeTourModalId = signal<string | null>(null) readonly filteredProperties = computed(() => { const f = this.activeCategoryFilter() if (f === 'all') return PROPERTIES return PROPERTIES.filter((p) => p.category === f) }) get hostClass(): string { return cn('bg-background text-foreground w-full space-y-6', this.className) } filterPillClass(value: string): string { return cn( 'focus-visible:ring-ring min-h-6 rounded-md px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none', this.activeCategoryFilter() === value ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground', ) } getPhotoIndex(id: string): number { return this.activePhotoIndex()[id] ?? 0 } prevPhoto(id: string, total: number): void { const current = this.getPhotoIndex(id) this.activePhotoIndex.update((prev) => ({ ...prev, [id]: (current - 1 + total) % total })) } nextPhoto(id: string, total: number): void { const current = this.getPhotoIndex(id) this.activePhotoIndex.update((prev) => ({ ...prev, [id]: (current + 1) % total })) } setPhoto(id: string, index: number): void { this.activePhotoIndex.update((prev) => ({ ...prev, [id]: index })) } toggleWishlist(id: string): void { this.wishlistedIds.update((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } isWishlisted(id: string): boolean { return this.wishlistedIds().has(id) } toggleScheduleTour(id: string): void { this.scheduledTourIds.update((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } isTourScheduled(id: string): boolean { return this.scheduledTourIds().has(id) } toggleContactAgent(id: string): void { this.contactedAgentIds.update((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } isAgentContacted(id: string): boolean { return this.contactedAgentIds().has(id) } shareListing(id: string): void { const url = `https://uipkge.dev/listings/${id}` try { void navigator?.clipboard?.writeText?.(url) } catch { // clipboard unavailable in test env } this.copiedLinkId.set(id) setTimeout(() => { if (this.copiedLinkId() === id) this.copiedLinkId.set(null) }, 2000) } toggleVirtualTour(id: string): void { this.activeTourModalId.update((curr) => (curr === id ? null : id)) } formatPrice(amount: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format( amount, ) } }src/app/components/blocks/property-listing-card/index.ts0.1 kB
export { UiPropertyListingCardComponent, PROPERTIES, type PropertyItem } from './property-listing-card.component'
Raw manifest:https://uipkge.dev/r/angular/property-listing-card.json