Menu

Change language

GitHub repo

Property Listing Card

blockreal-estate

Zillow 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

$npx uipkge-ng@latest add property-listing-card
Manifest:https://uipkge.dev/r/angular/property-listing-card.jsonInstalls to:src/app/components/blocks/property-listing-card/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

PropertyItem
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 }
}

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

Raw manifest:https://uipkge.dev/r/angular/property-listing-card.json