Menu

Change language

GitHub repo

Hotel Booking Reservation

blockcommerce

Luxury hotel room booking and reservation widget with interactive date range picker, guest & room steppers, room upgrade checkboxes, live reactive pricing breakdown, and instant reservation checkout.

Installation

$npx uipkge-ng@latest add hotel-booking-reservation
Manifest:https://uipkge.dev/r/angular/hotel-booking-reservation.jsonInstalls to:src/app/components/blocks/hotel-booking-reservation/

Variants

Loading interactive previews…

Schema

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

HotelBookingPhoto
interface HotelBookingPhoto {
  url: string
  title: string
}

Files installed (2)

  • src/app/components/blocks/hotel-booking-reservation/hotel-booking-reservation.component.ts45.1 kB
    // Edit this file to change the room, photos, upgrades and copy. State is local -- wire
    // `book()` to your own reservation API.
    import { Component, Input, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      Baby,
      Bath,
      BedDouble,
      Building2,
      Calendar,
      CalendarDays,
      Check,
      CircleCheck,
      Coffee,
      CreditCard,
      Heart,
      Info,
      Lock,
      LucideAngularModule,
      MapPin,
      Maximize2,
      Minus,
      Plus,
      RotateCcw,
      Share2,
      ShieldCheck,
      Star,
      Users,
      Waves,
      Wifi,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiCheckboxComponent } from '@/ui/checkbox/checkbox.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export type HotelBookingUpgrade = 'balcony' | 'breakfast' | 'chauffeur' | 'spa'
    
    export interface HotelBookingPhoto {
      url: string
      title: string
    }
    
    const roomPhotos: HotelBookingPhoto[] = [
      {
        url: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&auto=format&fit=crop&q=80',
        title: 'King Master Suite & Ocean Balcony',
      },
      {
        url: 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800&auto=format&fit=crop&q=80',
        title: 'Atlantic Ocean Sunrise Balcony',
      },
      {
        url: 'https://images.unsplash.com/photo-1584622650111-993a426fbf0a?w=800&auto=format&fit=crop&q=80',
        title: 'Italian Marble Spa Bathroom',
      },
      {
        url: 'https://images.unsplash.com/photo-1540541338287-41700207dee6?w=800&auto=format&fit=crop&q=80',
        title: 'Private Beach Club & Ocean Cabanas',
      },
    ]
    
    const currency = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    })
    
    function toIsoDate(date: Date): string {
      const y = date.getFullYear()
      const m = String(date.getMonth() + 1).padStart(2, '0')
      const d = String(date.getDate()).padStart(2, '0')
      return `${y}-${m}-${d}`
    }
    
    /**
     * HotelBookingReservation -- the React block, 1:1: a room hero (photo gallery with thumbnails,
     * rating, amenities, member rate), stay-date inputs with quick length presets, guest / room
     * steppers, toggleable upgrade rows, a special-requests input, and a sticky price breakdown
     * whose totals follow every change. Reserving shows a confirmation banner above the hero.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-hotel-booking-reservation, [ui-hotel-booking-reservation]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiCardDescriptionComponent,
        UiCardContentComponent,
        UiCardFooterComponent,
        UiCheckboxComponent,
        UiInputComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="hotel-booking-reservation" [class]="rootClass">
          <!-- Confirmation Banner (Visible when booked) -->
          @if (isBooked()) {
            <div
              class="border-success/30 bg-success/10 bg-success/15 relative flex flex-col gap-4 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
            >
              <div class="flex flex-wrap items-start gap-3.5">
                <div
                  class="bg-success/20 text-success mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full"
                >
                  <lucide-icon [img]="CircleCheck" class="size-5" />
                </div>
                <div class="space-y-1">
                  <div class="flex flex-wrap items-center gap-2">
                    <h3 class="text-foreground text-base font-semibold">Reservation Confirmed · Reference #RC-884920</h3>
                    <span ui-badge wrap variant="outline" class="border-success/40 text-success text-xs font-semibold">
                      Guaranteed Direct
                    </span>
                  </div>
                  <p class="text-muted-foreground text-xs sm:text-sm">
                    A confirmation voucher and check-in barcode have been sent to your registered email. No cancellation
                    penalty applies before {{ cancellationDeadline }}.
                  </p>
                </div>
              </div>
              <button ui-button variant="outline" size="sm" class="shrink-0 font-medium" (click)="isBooked.set(false)">
                <lucide-icon [img]="RotateCcw" class="size-3.5" />
                Modify Reservation
              </button>
            </div>
          }
    
          <!-- Room Hero Header Card -->
          <ui-card class="border-border overflow-hidden shadow-xs">
            <div class="grid grid-cols-1 gap-6 p-5 sm:p-6 lg:grid-cols-12 lg:gap-8">
              <!-- Room Photo Showcase -->
              <div class="space-y-3 lg:col-span-6">
                <div class="border-border bg-muted/40 relative aspect-16/10 w-full overflow-hidden rounded-xl border">
                  <img
                    [src]="activePhoto.url"
                    [alt]="activePhoto.title"
                    class="size-full object-cover transition-colors duration-300"
                  />
                  <div
                    class="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-black/20"
                  ></div>
                  <div class="absolute top-3 left-3 flex flex-wrap gap-1.5">
                    <span
                      ui-badge
                      wrap
                      variant="secondary"
                      class="bg-background/90 text-foreground text-xs font-medium backdrop-blur-xs"
                    >
                      Oceanfront View
                    </span>
                    <span
                      ui-badge
                      wrap
                      variant="outline"
                      class="bg-background/80 text-foreground border-white/20 text-xs backdrop-blur-xs"
                    >
                      Floor 16
                    </span>
                  </div>
                  <div class="absolute top-3 right-3 flex items-center gap-1.5">
                    <button
                      type="button"
                      aria-label="Save to favorites"
                      class="bg-background/80 hover:bg-background text-foreground focus-visible:ring-ring flex size-8 items-center justify-center rounded-full backdrop-blur-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
                      (click)="isSaved.set(!isSaved())"
                    >
                      <lucide-icon [img]="Heart" [class]="heartClass" />
                    </button>
                    <button
                      type="button"
                      aria-label="Share suite"
                      class="bg-background/80 hover:bg-background text-foreground focus-visible:ring-ring flex size-8 items-center justify-center rounded-full backdrop-blur-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
                    >
                      <lucide-icon [img]="Share2" class="size-4" />
                    </button>
                  </div>
                  <div class="absolute right-3 bottom-3 left-3 flex items-center justify-between text-xs text-white">
                    <span class="font-medium drop-shadow-xs">{{ activePhoto.title }}</span>
                    <span class="rounded bg-black/60 px-2 py-0.5 font-mono text-xs backdrop-blur-xs"
                      >{{ activePhotoIndex() + 1 }} / {{ photos.length }}</span
                    >
                  </div>
                </div>
    
                <!-- Mini Thumbnail Selector -->
                <div class="grid grid-cols-4 gap-2">
                  @for (photo of photos; track $index) {
                    <button
                      type="button"
                      [attr.aria-label]="photo.title"
                      [class]="thumbClass($index)"
                      (click)="activePhotoIndex.set($index)"
                    >
                      <img [src]="photo.url" [alt]="photo.title" class="size-full object-cover" />
                    </button>
                  }
                </div>
              </div>
    
              <!-- Room Meta & Overview Information -->
              <div class="flex flex-col justify-between space-y-4 lg:col-span-6">
                <div class="space-y-3">
                  <div class="flex flex-wrap items-center justify-between gap-2">
                    <div class="text-warning flex items-center gap-1.5 text-xs font-medium">
                      <lucide-icon [img]="Star" class="fill-warning text-warning size-4" />
                      <span class="font-bold tabular-nums">{{ rating.toFixed(1) }}</span>
                      <span class="text-muted-foreground underline underline-offset-2"
                        >{{ reviewsCount }} verified guest reviews</span
                      >
                    </div>
                    <span
                      ui-badge
                      wrap
                      variant="outline"
                      class="border-primary/30 bg-primary/5 text-primary text-xs font-medium"
                    >
                      Official Best Rate Guarantee
                    </span>
                  </div>
    
                  <div class="space-y-1">
                    <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{{ roomTitle }}</h1>
                    <div class="text-muted-foreground flex items-center gap-1.5 text-xs sm:text-sm">
                      <lucide-icon [img]="Building2" class="text-primary size-4 shrink-0" />
                      <span class="text-foreground font-medium">{{ hotelName }}</span>
                    </div>
                    <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                      <lucide-icon [img]="MapPin" class="size-3.5 shrink-0" />
                      <span>{{ location }}</span>
                    </div>
                  </div>
    
                  <p class="text-muted-foreground text-xs leading-relaxed sm:text-sm">
                    Expansive oceanfront sanctuary featuring custom King pillowtop bedding, floor-to-ceiling glass doors
                    opening onto an oversized private balcony with sweeping Atlantic panoramas, freestanding soaking tub,
                    and bespoke in-room dining.
                  </p>
    
                  <!-- Room Key Amenities Grid -->
                  <div class="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-2 md:grid-cols-3">
                    <div class="border-border/60 bg-muted/20 flex items-center gap-2 rounded-md border p-2 text-xs">
                      <lucide-icon [img]="Maximize2" class="text-muted-foreground size-3.5 shrink-0" />
                      <span class="text-foreground font-medium">680 sq ft / 63 m²</span>
                    </div>
                    <div class="border-border/60 bg-muted/20 flex items-center gap-2 rounded-md border p-2 text-xs">
                      <lucide-icon [img]="BedDouble" class="text-muted-foreground size-3.5 shrink-0" />
                      <span class="text-foreground font-medium">1 King Bed</span>
                    </div>
                    <div class="border-border/60 bg-muted/20 flex items-center gap-2 rounded-md border p-2 text-xs">
                      <lucide-icon [img]="Waves" class="text-muted-foreground size-3.5 shrink-0" />
                      <span class="text-foreground font-medium">Ocean Balcony</span>
                    </div>
                    <div class="border-border/60 bg-muted/20 flex items-center gap-2 rounded-md border p-2 text-xs">
                      <lucide-icon [img]="Bath" class="text-muted-foreground size-3.5 shrink-0" />
                      <span class="text-foreground font-medium">Marble Soaking Tub</span>
                    </div>
                    <div class="border-border/60 bg-muted/20 flex items-center gap-2 rounded-md border p-2 text-xs">
                      <lucide-icon [img]="Wifi" class="text-muted-foreground size-3.5 shrink-0" />
                      <span class="text-foreground font-medium">Fast Fiber WiFi</span>
                    </div>
                    <div class="border-border/60 bg-muted/20 flex items-center gap-2 rounded-md border p-2 text-xs">
                      <lucide-icon [img]="Coffee" class="text-muted-foreground size-3.5 shrink-0" />
                      <span class="text-foreground font-medium">Nespresso Machine</span>
                    </div>
                  </div>
                </div>
    
                <div
                  class="border-border/80 bg-muted/30 flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3.5 sm:p-4"
                >
                  <div>
                    <span class="text-muted-foreground block text-xs font-medium tracking-wider uppercase">
                      Direct Member Rate
                    </span>
                    <div class="flex flex-wrap items-baseline gap-2">
                      <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">
                        {{ formatCurrency(basePrice) }}
                      </span>
                      <span class="text-muted-foreground text-xs font-normal sm:text-sm">/ night</span>
                      <span class="text-muted-foreground text-xs tabular-nums line-through sm:text-sm">$560.00</span>
                    </div>
                  </div>
                  <span
                    ui-badge
                    wrap
                    variant="secondary"
                    class="border-success/20 bg-success/10 text-success text-xs font-semibold"
                  >
                    Save 13% Direct
                  </span>
                </div>
              </div>
            </div>
          </ui-card>
    
          <!-- Interactive 2-Column Booking Configuration & Price Breakdown Grid -->
          <div class="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:items-start">
            <!-- Left Column: Reservation Inputs, Guests & Upgrades -->
            <div class="space-y-6 lg:col-span-7">
              <!-- Stay Dates Selection -->
              <ui-card class="shadow-xs">
                <ui-card-header>
                  <div class="flex items-center justify-between">
                    <div class="space-y-0.5">
                      <ui-card-title class="text-lg">Stay Dates &amp; Duration</ui-card-title>
                      <ui-card-description>
                        Select your check-in and check-out dates to calculate live availability.
                      </ui-card-description>
                    </div>
                    <span ui-badge wrap variant="outline" class="gap-1 text-xs font-medium">
                      <lucide-icon [img]="CalendarDays" class="text-primary size-3.5" />
                      {{ nights }} {{ nights === 1 ? 'Night' : 'Nights' }}
                    </span>
                  </div>
                </ui-card-header>
                <ui-card-content class="space-y-5">
                  <!-- Date Inputs -->
                  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
                    <div class="space-y-2">
                      <label for="check-in-date" class="text-foreground flex items-center gap-1.5 text-xs font-semibold">
                        <lucide-icon [img]="Calendar" class="text-primary size-3.5" />
                        Check-in Date (3:00 PM)
                      </label>
                      <ui-input
                        id="check-in-date"
                        type="date"
                        [value]="checkInDate()"
                        (valueChange)="checkInDate.set($event)"
                      />
                    </div>
    
                    <div class="space-y-2">
                      <label for="check-out-date" class="text-foreground flex items-center gap-1.5 text-xs font-semibold">
                        <lucide-icon [img]="Calendar" class="text-primary size-3.5" />
                        Check-out Date (12:00 PM)
                      </label>
                      <ui-input
                        id="check-out-date"
                        type="date"
                        [value]="checkOutDate()"
                        (valueChange)="checkOutDate.set($event)"
                      />
                    </div>
                  </div>
    
                  <!-- Stay Summary Highlight & Quick Length Presets -->
                  <div
                    class="border-border/60 bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between"
                  >
                    <div class="text-foreground text-xs font-medium">
                      <span class="text-muted-foreground">Selected Stay: </span>
                      <span class="font-semibold">{{ formattedRange }}</span>
                    </div>
                    <div class="flex flex-wrap items-center gap-1.5">
                      <span class="text-muted-foreground mr-1 text-xs">Quick:</span>
                      @for (preset of presets; track preset) {
                        <button
                          ui-button
                          variant="outline"
                          size="xs"
                          [class]="presetClass(preset)"
                          (click)="setDuration(preset)"
                        >
                          {{ preset }}N
                        </button>
                      }
                    </div>
                  </div>
                </ui-card-content>
              </ui-card>
    
              <!-- Guests & Rooms Steppers -->
              <ui-card class="shadow-xs">
                <ui-card-header>
                  <ui-card-title class="text-lg">Guests &amp; Rooms</ui-card-title>
                  <ui-card-description>Configure the number of adults, children, and suites requested.</ui-card-description>
                </ui-card-header>
                <ui-card-content class="space-y-4">
                  <!-- Adults Stepper -->
                  <div class="border-border/60 flex flex-wrap items-center justify-between rounded-lg border p-3.5">
                    <div class="flex items-center gap-3">
                      <div
                        class="bg-primary/10 text-primary border-primary/20 flex size-9 shrink-0 items-center justify-center rounded-lg border"
                      >
                        <lucide-icon [img]="Users" class="size-4" />
                      </div>
                      <div class="space-y-0.5">
                        <span class="text-foreground text-sm font-medium">Adults</span>
                        <p class="text-muted-foreground text-xs">Ages 13 and above</p>
                      </div>
                    </div>
                    <div class="flex items-center gap-3">
                      <button
                        ui-button
                        variant="outline"
                        size="icon-sm"
                        [disabled]="adults() <= 1"
                        aria-label="Decrease adults"
                        (click)="updateAdults(-1)"
                      >
                        <lucide-icon [img]="Minus" class="size-3.5" />
                      </button>
                      <span class="text-foreground w-6 text-center text-sm font-bold tabular-nums">{{ adults() }}</span>
                      <button
                        ui-button
                        variant="outline"
                        size="icon-sm"
                        [disabled]="adults() >= 8"
                        aria-label="Increase adults"
                        (click)="updateAdults(1)"
                      >
                        <lucide-icon [img]="Plus" class="size-3.5" />
                      </button>
                    </div>
                  </div>
    
                  <!-- Children Stepper -->
                  <div class="border-border/60 flex flex-wrap items-center justify-between rounded-lg border p-3.5">
                    <div class="flex items-center gap-3">
                      <div
                        class="bg-primary/10 text-primary border-primary/20 flex size-9 shrink-0 items-center justify-center rounded-lg border"
                      >
                        <lucide-icon [img]="Baby" class="size-4" />
                      </div>
                      <div class="space-y-0.5">
                        <span class="text-foreground text-sm font-medium">Children</span>
                        <p class="text-muted-foreground text-xs">Ages 0 to 12 years</p>
                      </div>
                    </div>
                    <div class="flex items-center gap-3">
                      <button
                        ui-button
                        variant="outline"
                        size="icon-sm"
                        [disabled]="children() <= 0"
                        aria-label="Decrease children"
                        (click)="updateChildren(-1)"
                      >
                        <lucide-icon [img]="Minus" class="size-3.5" />
                      </button>
                      <span class="text-foreground w-6 text-center text-sm font-bold tabular-nums">{{ children() }}</span>
                      <button
                        ui-button
                        variant="outline"
                        size="icon-sm"
                        [disabled]="children() >= 6"
                        aria-label="Increase children"
                        (click)="updateChildren(1)"
                      >
                        <lucide-icon [img]="Plus" class="size-3.5" />
                      </button>
                    </div>
                  </div>
    
                  <!-- Rooms Stepper -->
                  <div class="border-border/60 flex flex-wrap items-center justify-between rounded-lg border p-3.5">
                    <div class="flex items-center gap-3">
                      <div
                        class="bg-primary/10 text-primary border-primary/20 flex size-9 shrink-0 items-center justify-center rounded-lg border"
                      >
                        <lucide-icon [img]="BedDouble" class="size-4" />
                      </div>
                      <div class="space-y-0.5">
                        <span class="text-foreground text-sm font-medium">Suites / Rooms</span>
                        <p class="text-muted-foreground text-xs">Max 4 guests per suite</p>
                      </div>
                    </div>
                    <div class="flex items-center gap-3">
                      <button
                        ui-button
                        variant="outline"
                        size="icon-sm"
                        [disabled]="rooms() <= 1"
                        aria-label="Decrease rooms"
                        (click)="updateRooms(-1)"
                      >
                        <lucide-icon [img]="Minus" class="size-3.5" />
                      </button>
                      <span class="text-foreground w-6 text-center text-sm font-bold tabular-nums">{{ rooms() }}</span>
                      <button
                        ui-button
                        variant="outline"
                        size="icon-sm"
                        [disabled]="rooms() >= 4"
                        aria-label="Increase rooms"
                        (click)="updateRooms(1)"
                      >
                        <lucide-icon [img]="Plus" class="size-3.5" />
                      </button>
                    </div>
                  </div>
                </ui-card-content>
              </ui-card>
    
              <!-- Room Upgrades & Add-ons -->
              <ui-card class="shadow-xs">
                <ui-card-header>
                  <div class="flex items-center justify-between">
                    <div class="space-y-0.5">
                      <ui-card-title class="text-lg">Room Upgrades &amp; Exclusive Services</ui-card-title>
                      <ui-card-description>
                        Tailor your stay with luxury amenities and curated hotel enhancements.
                      </ui-card-description>
                    </div>
                    <span ui-badge wrap variant="secondary" class="text-xs">{{ selectedUpgrades().length }} Selected</span>
                  </div>
                </ui-card-header>
                <ui-card-content class="space-y-3">
                  <!-- Upgrade 1: Oceanfront Balcony -->
                  <div
                    role="button"
                    tabindex="0"
                    [class]="upgradeClass('balcony')"
                    (click)="toggleUpgrade('balcony')"
                    (keydown)="onUpgradeKeydown($event, 'balcony')"
                  >
                    <div class="flex flex-wrap items-start gap-3.5">
                      <ui-checkbox id="upgrade-balcony" [checked]="has('balcony')" class="pointer-events-none mt-1" />
                      <div class="space-y-1">
                        <div class="flex flex-wrap items-center gap-2">
                          <span class="text-foreground text-sm font-semibold">Oceanfront High-Floor Balcony</span>
                          <span ui-badge wrap variant="outline" class="text-xs font-medium">+$50 / night</span>
                        </div>
                        <p class="text-muted-foreground text-xs leading-relaxed">
                          Guaranteed unobstructed 180° Atlantic Ocean sunrise view on floors 15+ with premium teak deck
                          loungers.
                        </p>
                      </div>
                    </div>
                    <div class="shrink-0 text-right">
                      <span class="text-foreground text-sm font-bold tabular-nums"
                        >+{{ formatCurrency(50 * nights * rooms()) }}</span
                      >
                      <span class="text-muted-foreground block text-xs">for {{ nights }} nights</span>
                    </div>
                  </div>
    
                  <!-- Upgrade 2: Gourmet Breakfast -->
                  <div
                    role="button"
                    tabindex="0"
                    [class]="upgradeClass('breakfast')"
                    (click)="toggleUpgrade('breakfast')"
                    (keydown)="onUpgradeKeydown($event, 'breakfast')"
                  >
                    <div class="flex flex-wrap items-start gap-3.5">
                      <ui-checkbox id="upgrade-breakfast" [checked]="has('breakfast')" class="pointer-events-none mt-1" />
                      <div class="space-y-1">
                        <div class="flex flex-wrap items-center gap-2">
                          <span class="text-foreground text-sm font-semibold">Daily Gourmet Breakfast</span>
                          <span ui-badge wrap variant="outline" class="text-xs font-medium">+$35 / person / day</span>
                        </div>
                        <p class="text-muted-foreground text-xs leading-relaxed">
                          Artisanal morning buffet at Artisan Beach House or private veranda in-room breakfast with fresh
                          juices.
                        </p>
                        <!-- Guest selection when active -->
                        @if (has('breakfast')) {
                          <div class="flex flex-wrap items-center gap-2 pt-1.5 text-xs" (click)="$event.stopPropagation()">
                            <span class="text-muted-foreground">Breakfast plan for:</span>
                            <div class="flex flex-wrap items-center gap-1.5">
                              @for (count of breakfastOptions; track count) {
                                <button
                                  ui-button
                                  variant="outline"
                                  size="xs"
                                  [class]="breakfastClass(count)"
                                  (click)="breakfastGuests.set(count)"
                                >
                                  {{ count }} {{ count === 1 ? 'Guest' : 'Guests' }}
                                </button>
                              }
                            </div>
                          </div>
                        }
                      </div>
                    </div>
                    <div class="shrink-0 text-right">
                      <span class="text-foreground text-sm font-bold tabular-nums"
                        >+{{ formatCurrency(35 * breakfastGuests() * nights) }}</span
                      >
                      <span class="text-muted-foreground block text-xs"
                        >{{ breakfastGuests() }} {{ breakfastGuests() === 1 ? 'guest' : 'guests' }} · {{ nights }}d</span
                      >
                    </div>
                  </div>
    
                  <!-- Upgrade 3: Airport Chauffeur -->
                  <div
                    role="button"
                    tabindex="0"
                    [class]="upgradeClass('chauffeur')"
                    (click)="toggleUpgrade('chauffeur')"
                    (keydown)="onUpgradeKeydown($event, 'chauffeur')"
                  >
                    <div class="flex flex-wrap items-start gap-3.5">
                      <ui-checkbox id="upgrade-chauffeur" [checked]="has('chauffeur')" class="pointer-events-none mt-1" />
                      <div class="space-y-1">
                        <div class="flex flex-wrap items-center gap-2">
                          <span class="text-foreground text-sm font-semibold">Private Airport Luxury Chauffeur</span>
                          <span ui-badge wrap variant="outline" class="text-xs font-medium">+$120 one-time</span>
                        </div>
                        <p class="text-muted-foreground text-xs leading-relaxed">
                          Curbside meet-and-greet with dedicated Cadillac Escalade or Mercedes S-Class transfer from MIA or
                          FLL.
                        </p>
                      </div>
                    </div>
                    <div class="shrink-0 text-right">
                      <span class="text-foreground text-sm font-bold tabular-nums">+$120.00</span>
                      <span class="text-muted-foreground block text-xs">flat one-time</span>
                    </div>
                  </div>
                </ui-card-content>
              </ui-card>
    
              <!-- Special Requests Card -->
              <ui-card class="shadow-xs">
                <ui-card-header class="pb-3">
                  <ui-card-title class="text-base">Special Requests &amp; Arrival Notes</ui-card-title>
                  <ui-card-description>
                    Let us know if you are celebrating a special occasion or require early check-in.
                  </ui-card-description>
                </ui-card-header>
                <ui-card-content>
                  <ui-input
                    id="special-requests"
                    [value]="specialRequests()"
                    (valueChange)="specialRequests.set($event)"
                    placeholder="e.g., Honeymoon celebration, feather-free bedding, quiet high floor..."
                    [maxLength]="200"
                    showCount
                  />
                </ui-card-content>
              </ui-card>
            </div>
    
            <!-- Right Column: Total Price Breakdown & Checkout Card (Sticky) -->
            <div class="lg:sticky lg:top-8 lg:col-span-5">
              <ui-card class="border-border bg-card shadow-xs">
                <ui-card-header class="pb-4">
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                      Stay Price Breakdown
                    </span>
                    <span
                      ui-badge
                      wrap
                      variant="secondary"
                      class="bg-primary/10 text-primary border-primary/20 gap-1 text-xs font-medium"
                    >
                      <lucide-icon [img]="CircleCheck" class="size-3" />
                      Instant Confirmation
                    </span>
                  </div>
    
                  <div class="mt-3 space-y-1">
                    <div class="text-muted-foreground text-xs">Total Stay Price (USD)</div>
                    <div class="flex items-baseline gap-2">
                      <span class="text-foreground text-3xl font-bold tracking-tight tabular-nums sm:text-4xl">
                        {{ formatCurrency(totalStayPrice) }}
                      </span>
                    </div>
                    <p class="text-muted-foreground text-xs">
                      Total for <span class="text-foreground font-medium">{{ nights }} nights</span> · {{ rooms() }} suite ·
                      {{ adults() + children() }} guests
                    </p>
                  </div>
                </ui-card-header>
    
                <ui-card-content class="space-y-4">
                  <ui-separator />
    
                  <!-- Itemized Pricing Breakdown -->
                  <div class="space-y-2.5">
                    <!-- Base Room Subtotal -->
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground"
                        >Room subtotal ({{ formatCurrency(basePrice) }} × {{ nights }} {{ nights === 1 ? 'night' : 'nights'
                        }}{{ rooms() > 1 ? ' × ' + rooms() + ' rooms' : '' }})</span
                      >
                      <span class="text-foreground font-semibold tabular-nums">{{ formatCurrency(roomSubtotal) }}</span>
                    </div>
    
                    <!-- Selected Add-ons summary / itemized -->
                    <div class="space-y-1.5">
                      <div class="flex items-center justify-between text-sm">
                        <span class="text-muted-foreground">Selected Add-ons ({{ selectedUpgrades().length }} active)</span>
                        <span class="text-foreground font-semibold tabular-nums">
                          {{ formatCurrency(selectedAddonsTotal) }}
                        </span>
                      </div>
    
                      <!-- Addon sub-items -->
                      @if (selectedUpgrades().length > 0) {
                        <div class="border-border/60 bg-muted/20 space-y-1 rounded-md border p-2.5 text-xs">
                          @if (has('balcony')) {
                            <div class="text-muted-foreground flex items-center justify-between">
                              <span>• Oceanfront Balcony ({{ formatCurrency(50) }} × {{ nights }}n)</span>
                              <span class="text-foreground font-medium tabular-nums">{{
                                formatCurrency(balconyCost)
                              }}</span>
                            </div>
                          }
                          @if (has('breakfast')) {
                            <div class="text-muted-foreground flex items-center justify-between">
                              <span
                                >• Gourmet Breakfast ({{ formatCurrency(35) }} × {{ breakfastGuests() }}g ×
                                {{ nights }}d)</span
                              >
                              <span class="text-foreground font-medium tabular-nums">{{
                                formatCurrency(breakfastCost)
                              }}</span>
                            </div>
                          }
                          @if (has('chauffeur')) {
                            <div class="text-muted-foreground flex items-center justify-between">
                              <span>• Luxury Airport Chauffeur (one-time)</span>
                              <span class="text-foreground font-medium tabular-nums">{{
                                formatCurrency(chauffeurCost)
                              }}</span>
                            </div>
                          }
                          @if (has('spa')) {
                            <div class="text-muted-foreground flex items-center justify-between">
                              <span>• Thermal Spa Pass ({{ formatCurrency(65) }} × {{ adults() }} guests)</span>
                              <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(spaCost) }}</span>
                            </div>
                          }
                        </div>
                      }
                    </div>
    
                    <!-- Resort Fee -->
                    <div class="flex items-center justify-between text-sm">
                      <div class="flex items-center gap-1">
                        <span class="text-muted-foreground">Resort &amp; Hospitality Fee</span>
                        <lucide-icon [img]="Info" class="text-muted-foreground/70 size-3.5 cursor-help" />
                      </div>
                      <span class="text-foreground font-semibold tabular-nums">{{ formatCurrency(resortFee) }}</span>
                    </div>
                    <div class="text-muted-foreground pl-0.5 text-xs">
                      $30.00 / night · Beach loungers, high-speed WiFi, welcome champagne
                    </div>
    
                    <!-- Estimated Taxes -->
                    <div class="flex items-center justify-between text-sm">
                      <span class="text-muted-foreground">Estimated Taxes &amp; Tourism Surcharge (14%)</span>
                      <span class="text-foreground font-semibold tabular-nums">{{ formatCurrency(estimatedTaxes) }}</span>
                    </div>
                  </div>
    
                  <ui-separator />
    
                  <!-- Cancellation Policy Banner -->
                  <div class="border-success/20 bg-success/10 bg-success/15 space-y-1 rounded-xl border p-3.5">
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="ShieldCheck" class="text-success size-4 shrink-0" />
                      <span class="text-foreground text-xs font-semibold">
                        Free cancellation until 48 hours before check-in
                      </span>
                    </div>
                    <p class="text-muted-foreground pl-6 text-xs leading-normal">
                      Cancel before <span class="text-foreground font-medium">{{ cancellationDeadline }}</span> for a 100%
                      full refund with zero fees.
                    </p>
                  </div>
    
                  <!-- Trust Bullet Points -->
                  <ul class="text-muted-foreground space-y-2 text-xs">
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" />
                      <span>No upfront booking fees or surprise service surcharges</span>
                    </li>
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" />
                      <span>Official Ritz-Carlton direct member rewards applied</span>
                    </li>
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Lock" class="text-primary size-3.5 shrink-0" />
                      <span>256-bit encrypted secure bank checkout</span>
                    </li>
                  </ul>
                </ui-card-content>
    
                <ui-card-footer class="flex flex-col gap-2.5 pt-2">
                  <button ui-button class="w-full gap-2 font-semibold shadow-xs" size="lg" (click)="book()">
                    <lucide-icon [img]="CreditCard" class="size-4" />
                    <span>Reserve &amp; Pay Now ({{ formatCurrency(totalStayPrice) }})</span>
                  </button>
                  <button ui-button variant="outline" class="w-full text-xs font-medium" size="default" (click)="book()">
                    Hold Room · Pay at Check-in
                  </button>
                  <p class="text-muted-foreground text-center text-xs">
                    You won't be charged yet. Final confirmation is sent instantly.
                  </p>
                </ui-card-footer>
              </ui-card>
            </div>
          </div>
        </div>
      `,
    })
    export class UiHotelBookingReservationComponent implements OnInit {
      @Input('class') className?: string
      @Input() roomTitle = 'Deluxe Ocean View King Suite'
      @Input() hotelName = 'The Ritz-Carlton Bal Harbour, Miami'
      @Input() location = 'Bal Harbour, Miami Beach, FL'
      @Input() rating = 5.0
      @Input() reviewsCount = 120
      @Input() basePrice = 485
      @Input() initialCheckIn = '2026-08-24'
      @Input() initialCheckOut = '2026-08-29'
      @Input() initialAdults = 2
      @Input() initialChildren = 1
      @Input() initialRooms = 1
    
      readonly checkInDate = signal('')
      readonly checkOutDate = signal('')
      readonly adults = signal(2)
      readonly children = signal(1)
      readonly rooms = signal(1)
      readonly specialRequests = signal('')
      readonly isBooked = signal(false)
      readonly isSaved = signal(false)
      readonly activePhotoIndex = signal(0)
    
      // Upgrades state: Oceanfront balcony and Daily Breakfast are checked by default
      readonly selectedUpgrades = signal<HotelBookingUpgrade[]>(['balcony', 'breakfast'])
      readonly breakfastGuests = signal(1)
    
      protected readonly photos = roomPhotos
      protected readonly presets = [3, 5, 7, 10]
      protected readonly breakfastOptions = [1, 2]
    
      protected readonly Baby = Baby
      protected readonly Bath = Bath
      protected readonly BedDouble = BedDouble
      protected readonly Building2 = Building2
      protected readonly Calendar = Calendar
      protected readonly CalendarDays = CalendarDays
      protected readonly Check = Check
      protected readonly CircleCheck = CircleCheck
      protected readonly Coffee = Coffee
      protected readonly CreditCard = CreditCard
      protected readonly Heart = Heart
      protected readonly Info = Info
      protected readonly Lock = Lock
      protected readonly MapPin = MapPin
      protected readonly Maximize2 = Maximize2
      protected readonly Minus = Minus
      protected readonly Plus = Plus
      protected readonly RotateCcw = RotateCcw
      protected readonly Share2 = Share2
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Star = Star
      protected readonly Users = Users
      protected readonly Waves = Waves
      protected readonly Wifi = Wifi
    
      ngOnInit(): void {
        // React seeds its useState once from the initial* props; inputs land after construction.
        this.checkInDate.set(this.initialCheckIn)
        this.checkOutDate.set(this.initialCheckOut)
        this.adults.set(this.initialAdults)
        this.children.set(this.initialChildren)
        this.rooms.set(this.initialRooms)
      }
    
      protected get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-8 p-4 sm:p-6 lg:p-8', this.className)
      }
    
      protected get activePhoto(): HotelBookingPhoto {
        return this.photos[this.activePhotoIndex()] ?? this.photos[0]!
      }
    
      has(id: HotelBookingUpgrade): boolean {
        return this.selectedUpgrades().includes(id)
      }
    
      toggleUpgrade(id: HotelBookingUpgrade): void {
        this.selectedUpgrades.update((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]))
      }
    
      onUpgradeKeydown(event: KeyboardEvent, id: HotelBookingUpgrade): void {
        if (event.key === ' ' || event.key === 'Enter') {
          event.preventDefault()
          this.toggleUpgrade(id)
        }
      }
    
      // Date calculations
      get nights(): number {
        const checkIn = this.checkInDate()
        const checkOut = this.checkOutDate()
        if (!checkIn || !checkOut) return 5
        const start = new Date(checkIn).getTime()
        const end = new Date(checkOut).getTime()
        const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24))
        return diffDays > 0 ? diffDays : 1
      }
    
      setDuration(presetNights: number): void {
        const checkIn = this.checkInDate()
        if (!checkIn) return
        const start = new Date(checkIn + 'T00:00:00')
        start.setDate(start.getDate() + presetNights)
        this.checkOutDate.set(toIsoDate(start))
      }
    
      formatDateDisplay(dateStr: string): string {
        if (!dateStr) return ''
        const [year, month, day] = dateStr.split('-').map(Number)
        if (!year || !month || !day) return dateStr
        const date = new Date(year, month - 1, day)
        return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
      }
    
      get formattedRange(): string {
        const nights = this.nights
        return `${this.formatDateDisplay(this.checkInDate())} → ${this.formatDateDisplay(this.checkOutDate())} · ${nights} ${nights === 1 ? 'Night' : 'Nights'}`
      }
    
      get cancellationDeadline(): string {
        const checkIn = this.checkInDate()
        if (!checkIn) return '48 hours prior to arrival'
        const start = new Date(checkIn + 'T00:00:00')
        start.setDate(start.getDate() - 2)
        return this.formatDateDisplay(toIsoDate(start))
      }
    
      // Financial calculations
      get roomSubtotal(): number {
        return this.basePrice * this.nights * this.rooms()
      }
      get balconyCost(): number {
        return this.has('balcony') ? 50 * this.nights * this.rooms() : 0
      }
      get breakfastCost(): number {
        return this.has('breakfast') ? 35 * this.breakfastGuests() * this.nights : 0
      }
      get chauffeurCost(): number {
        return this.has('chauffeur') ? 120 : 0
      }
      get spaCost(): number {
        return this.has('spa') ? 65 * this.adults() : 0
      }
      get selectedAddonsTotal(): number {
        return this.balconyCost + this.breakfastCost + this.chauffeurCost + this.spaCost
      }
      get resortFee(): number {
        return 30 * this.nights * this.rooms()
      }
      get taxableSubtotal(): number {
        return this.roomSubtotal + this.selectedAddonsTotal + this.resortFee
      }
      get estimatedTaxes(): number {
        return Math.round(this.taxableSubtotal * 0.14 * 100) / 100
      }
      get totalStayPrice(): number {
        return this.taxableSubtotal + this.estimatedTaxes
      }
    
      formatCurrency(val: number): string {
        return currency.format(val)
      }
    
      // Stepper modifiers
      updateAdults(delta: number): void {
        const next = this.adults() + delta
        if (next >= 1 && next <= 8) {
          if (this.breakfastGuests() > next) this.breakfastGuests.set(next)
          this.adults.set(next)
        }
      }
    
      updateChildren(delta: number): void {
        const next = this.children() + delta
        if (next >= 0 && next <= 6) this.children.set(next)
      }
    
      updateRooms(delta: number): void {
        const next = this.rooms() + delta
        if (next >= 1 && next <= 4) this.rooms.set(next)
      }
    
      book(): void {
        this.isBooked.set(true)
      }
    
      protected get heartClass(): string {
        return cn('size-4 transition-colors', this.isSaved() ? 'fill-destructive text-destructive' : 'text-foreground')
      }
    
      protected thumbClass(idx: number): string {
        return cn(
          'border-border relative aspect-16/10 overflow-hidden rounded-lg border transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
          this.activePhotoIndex() === idx ? 'ring-primary border-primary ring-2' : 'opacity-70 hover:opacity-100',
        )
      }
    
      protected presetClass(preset: number): string {
        return this.nights === preset ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90' : ''
      }
    
      protected breakfastClass(count: number): string {
        return this.breakfastGuests() === count ? 'bg-primary text-primary-foreground border-primary' : ''
      }
    
      protected upgradeClass(id: HotelBookingUpgrade): string {
        return cn(
          'border-border focus-visible:ring-ring flex cursor-pointer items-start justify-between gap-4 rounded-xl border p-4 transition-colors select-none focus-visible:ring-2 focus-visible:outline-none',
          this.has(id)
            ? 'border-primary/40 bg-primary/5 shadow-xs'
            : 'bg-card hover:border-border/80 opacity-80 hover:opacity-100',
        )
      }
    }
    
  • src/app/components/blocks/hotel-booking-reservation/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/hotel-booking-reservation.json