Hotel Booking Reservation
blockcommerceLuxury 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
$pnpm dlx uipkge-ng@latest add hotel-booking-reservation$npx uipkge-ng@latest add hotel-booking-reservation$yarn dlx uipkge-ng@latest add hotel-booking-reservation$bunx uipkge-ng@latest add hotel-booking-reservationManifest:
https://uipkge.dev/r/angular/hotel-booking-reservation.jsonInstalls to:src/app/components/blocks/hotel-booking-reservation/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.
HotelBookingPhotointerface 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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
export { UiHotelBookingReservationComponent, type HotelBookingPhoto, type HotelBookingUpgrade, } from './hotel-booking-reservation.component'
Raw manifest:https://uipkge.dev/r/angular/hotel-booking-reservation.json