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 shadcn-vue@latest add https://uipkge.dev/r/vue/hotel-booking-reservation.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/hotel-booking-reservation.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/hotel-booking-reservation.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/hotel-booking-reservation.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/hotel-booking-reservationInstalls to:app/components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
roomTitle | string | 'Deluxe Ocean View King Suite' | optional |
hotelName | string | 'The Ritz-Carlton Bal Harbour | optional |
location | string | 'Bal Harbour | optional |
rating | number | 5.0 | optional |
reviewsCount | number | 120 | optional |
basePrice | number | 485 | optional |
initialCheckIn | string | '2026-08-24' | optional |
initialCheckOut | string | '2026-08-29' | optional |
initialAdults | number | 2 | optional |
initialChildren | number | 1 | optional |
initialRooms | number | 1 | optional |
Files installed (1)
app/components/blocks/HotelBookingReservation.vue40.4 kB
<script setup lang="ts"> import { computed, ref, type HTMLAttributes } from 'vue' import { Baby, Bath, BedDouble, Building2, Calendar, CalendarDays, Check, CheckCircle2, Coffee, CreditCard, Heart, Info, Lock, MapPin, Maximize2, Minus, Plus, RotateCcw, Share2, ShieldCheck, Star, Users, Waves, Wifi, } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' interface Props { class?: HTMLAttributes['class'] roomTitle?: string hotelName?: string location?: string rating?: number reviewsCount?: number basePrice?: number initialCheckIn?: string initialCheckOut?: string initialAdults?: number initialChildren?: number initialRooms?: number } const props = withDefaults(defineProps<Props>(), { roomTitle: 'Deluxe Ocean View King Suite', hotelName: 'The Ritz-Carlton Bal Harbour, Miami', location: 'Bal Harbour, Miami Beach, FL', rating: 5.0, reviewsCount: 120, basePrice: 485, initialCheckIn: '2026-08-24', initialCheckOut: '2026-08-29', initialAdults: 2, initialChildren: 1, initialRooms: 1, }) const checkInDate = ref(props.initialCheckIn) const checkOutDate = ref(props.initialCheckOut) const adults = ref(props.initialAdults) const children = ref(props.initialChildren) const rooms = ref(props.initialRooms) const specialRequests = ref('') const isBooked = ref(false) const isSaved = ref(false) const activePhotoIndex = ref(0) const roomPhotos = [ { 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', }, ] // Upgrades state: Oceanfront balcony and Daily Breakfast are checked by default const selectedUpgrades = ref<string[]>(['balcony', 'breakfast']) const breakfastGuests = ref(1) function toggleUpgrade(id: string) { if (selectedUpgrades.value.includes(id)) { selectedUpgrades.value = selectedUpgrades.value.filter((item) => item !== id) } else { selectedUpgrades.value = [...selectedUpgrades.value, id] } } // Date calculations const nights = computed(() => { if (!checkInDate.value || !checkOutDate.value) return 5 const start = new Date(checkInDate.value).getTime() const end = new Date(checkOutDate.value).getTime() const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24)) return diffDays > 0 ? diffDays : 1 }) function setDuration(presetNights: number) { if (!checkInDate.value) return const start = new Date(checkInDate.value + 'T00:00:00') start.setDate(start.getDate() + presetNights) const y = start.getFullYear() const m = String(start.getMonth() + 1).padStart(2, '0') const d = String(start.getDate()).padStart(2, '0') checkOutDate.value = `${y}-${m}-${d}` } function formatDateDisplay(dateStr: 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' }) } const formattedRange = computed(() => { return `${formatDateDisplay(checkInDate.value)} → ${formatDateDisplay(checkOutDate.value)} · ${nights.value} ${nights.value === 1 ? 'Night' : 'Nights'}` }) const cancellationDeadline = computed(() => { if (!checkInDate.value) return '48 hours prior to arrival' const start = new Date(checkInDate.value + 'T00:00:00') start.setDate(start.getDate() - 2) const y = start.getFullYear() const m = String(start.getMonth() + 1).padStart(2, '0') const d = String(start.getDate()).padStart(2, '0') return formatDateDisplay(`${y}-${m}-${d}`) }) // Financial calculations const roomSubtotal = computed(() => { return props.basePrice * nights.value * rooms.value }) const balconyCost = computed(() => { return selectedUpgrades.value.includes('balcony') ? 50 * nights.value * rooms.value : 0 }) const breakfastCost = computed(() => { return selectedUpgrades.value.includes('breakfast') ? 35 * breakfastGuests.value * nights.value : 0 }) const chauffeurCost = computed(() => { return selectedUpgrades.value.includes('chauffeur') ? 120 : 0 }) const spaCost = computed(() => { return selectedUpgrades.value.includes('spa') ? 65 * adults.value : 0 }) const selectedAddonsTotal = computed(() => { return balconyCost.value + breakfastCost.value + chauffeurCost.value + spaCost.value }) const resortFee = computed(() => { // $30 resort & hospitality fee per night per room return 30 * nights.value * rooms.value }) const taxableSubtotal = computed(() => { return roomSubtotal.value + selectedAddonsTotal.value + resortFee.value }) const estimatedTaxes = computed(() => { // 14% state & hospitality tourism tax return Math.round(taxableSubtotal.value * 0.14 * 100) / 100 }) const totalStayPrice = computed(() => { return taxableSubtotal.value + estimatedTaxes.value }) function formatCurrency(val: number) { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(val) } // Stepper modifiers function updateAdults(delta: number) { const next = adults.value + delta if (next >= 1 && next <= 8) { adults.value = next if (breakfastGuests.value > next) { breakfastGuests.value = next } } } function updateChildren(delta: number) { const next = children.value + delta if (next >= 0 && next <= 6) { children.value = next } } function updateRooms(delta: number) { const next = rooms.value + delta if (next >= 1 && next <= 4) { rooms.value = next } } function handleReserve() { isBooked.value = true } function resetBooking() { isBooked.value = false } </script> <template> <div data-slot="hotel-booking-reservation" :class="cn('mx-auto w-full max-w-6xl space-y-8 p-4 sm:p-6 lg:p-8', props.class)" > <!-- Confirmation Banner (Visible when booked) --> <div v-if="isBooked" 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"> <CheckCircle2 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> <Badge wrap variant="outline" class="border-success/40 text-success text-xs font-semibold"> Guaranteed Direct </Badge> </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 variant="outline" size="sm" class="shrink-0 font-medium" @click="resetBooking"> <RotateCcw class="size-3.5" /> Modify Reservation </Button> </div> <!-- Room Hero Header Card --> <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="roomPhotos[activePhotoIndex]?.url" :alt="roomPhotos[activePhotoIndex]?.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 class="absolute top-3 left-3 flex flex-wrap gap-1.5"> <Badge wrap variant="secondary" class="bg-background/90 text-foreground text-xs font-medium backdrop-blur-xs" > Oceanfront View </Badge> <Badge wrap variant="outline" class="bg-background/80 text-foreground border-white/20 text-xs backdrop-blur-xs" > Floor 16 </Badge> </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 flex size-8 items-center justify-center rounded-full backdrop-blur-xs transition-colors" @click="isSaved = !isSaved" > <Heart :class=" cn('size-4 transition-colors', isSaved ? 'fill-destructive text-destructive' : 'text-foreground') " /> </button> <button type="button" aria-label="Share suite" class="bg-background/80 hover:bg-background text-foreground flex size-8 items-center justify-center rounded-full backdrop-blur-xs transition-colors" > <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">{{ roomPhotos[activePhotoIndex]?.title }}</span> <span class="rounded bg-black/60 px-2 py-0.5 font-mono text-xs backdrop-blur-xs"> {{ activePhotoIndex + 1 }} / {{ roomPhotos.length }} </span> </div> </div> <!-- Mini Thumbnail Selector --> <div class="grid grid-cols-4 gap-2"> <button v-for="(photo, idx) in roomPhotos" :key="idx" type="button" :aria-label="photo.title" :class=" cn( 'border-border relative aspect-16/10 overflow-hidden rounded-lg border transition-colors', activePhotoIndex === idx ? 'ring-primary border-primary ring-2' : 'opacity-70 hover:opacity-100', ) " @click="activePhotoIndex = idx" > <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"> <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> <Badge wrap variant="outline" class="border-primary/30 bg-primary/5 text-primary text-xs font-medium"> Official Best Rate Guarantee </Badge> </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"> <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"> <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"> <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"> <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"> <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"> <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"> <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"> <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> <Badge wrap variant="secondary" class="border-success/20 bg-success/10 text-success text-xs font-semibold"> Save 13% Direct </Badge> </div> </div> </div> </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 --> <Card class="shadow-xs"> <CardHeader> <div class="flex items-center justify-between"> <div class="space-y-0.5"> <CardTitle class="text-lg">Stay Dates & Duration</CardTitle> <CardDescription >Select your check-in and check-out dates to calculate live availability.</CardDescription > </div> <Badge wrap variant="outline" class="gap-1 text-xs font-medium"> <CalendarDays class="text-primary size-3.5" /> {{ nights }} {{ nights === 1 ? 'Night' : 'Nights' }} </Badge> </div> </CardHeader> <CardContent 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"> <Calendar class="text-primary size-3.5" /> Check-in Date (3:00 PM) </label> <Input id="check-in-date" type="date" :model-value="checkInDate" @update:model-value="(val) => (checkInDate = String(val))" /> </div> <div class="space-y-2"> <label for="check-out-date" class="text-foreground flex items-center gap-1.5 text-xs font-semibold"> <Calendar class="text-primary size-3.5" /> Check-out Date (12:00 PM) </label> <Input id="check-out-date" type="date" :model-value="checkOutDate" @update:model-value="(val) => (checkOutDate = String(val))" /> </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> <Button v-for="preset in [3, 5, 7, 10]" :key="preset" variant="outline" size="xs" :class=" nights === preset ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90' : '' " @click="setDuration(preset)" > {{ preset }}N </Button> </div> </div> </CardContent> </Card> <!-- Guests & Rooms Steppers --> <Card class="shadow-xs"> <CardHeader> <CardTitle class="text-lg">Guests & Rooms</CardTitle> <CardDescription>Configure the number of adults, children, and suites requested.</CardDescription> </CardHeader> <CardContent 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" > <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 variant="outline" size="icon-sm" :disabled="adults <= 1" aria-label="Decrease adults" @click="updateAdults(-1)" > <Minus class="size-3.5" /> </Button> <span class="text-foreground w-6 text-center text-sm font-bold tabular-nums">{{ adults }}</span> <Button variant="outline" size="icon-sm" :disabled="adults >= 8" aria-label="Increase adults" @click="updateAdults(1)" > <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" > <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 variant="outline" size="icon-sm" :disabled="children <= 0" aria-label="Decrease children" @click="updateChildren(-1)" > <Minus class="size-3.5" /> </Button> <span class="text-foreground w-6 text-center text-sm font-bold tabular-nums">{{ children }}</span> <Button variant="outline" size="icon-sm" :disabled="children >= 6" aria-label="Increase children" @click="updateChildren(1)" > <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" > <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 variant="outline" size="icon-sm" :disabled="rooms <= 1" aria-label="Decrease rooms" @click="updateRooms(-1)" > <Minus class="size-3.5" /> </Button> <span class="text-foreground w-6 text-center text-sm font-bold tabular-nums">{{ rooms }}</span> <Button variant="outline" size="icon-sm" :disabled="rooms >= 4" aria-label="Increase rooms" @click="updateRooms(1)" > <Plus class="size-3.5" /> </Button> </div> </div> </CardContent> </Card> <!-- Room Upgrades & Add-ons --> <Card class="shadow-xs"> <CardHeader> <div class="flex items-center justify-between"> <div class="space-y-0.5"> <CardTitle class="text-lg">Room Upgrades & Exclusive Services</CardTitle> <CardDescription >Tailor your stay with luxury amenities and curated hotel enhancements.</CardDescription > </div> <Badge wrap variant="secondary" class="text-xs"> {{ selectedUpgrades.length }} Selected </Badge> </div> </CardHeader> <CardContent class="space-y-3"> <!-- Upgrade 1: Oceanfront Balcony --> <div role="button" tabindex="0" :class=" 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', selectedUpgrades.includes('balcony') ? 'border-primary/40 bg-primary/5 shadow-xs' : 'bg-card hover:border-border/80 opacity-80 hover:opacity-100', ) " @click="toggleUpgrade('balcony')" @keydown.space.prevent="toggleUpgrade('balcony')" @keydown.enter.prevent="toggleUpgrade('balcony')" > <div class="flex flex-wrap items-start gap-3.5"> <Checkbox id="upgrade-balcony" :model-value="selectedUpgrades.includes('balcony')" class="pointer-events-none mt-1" tabindex="-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> <Badge wrap variant="outline" class="text-xs font-medium">+$50 / night</Badge> </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=" 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', selectedUpgrades.includes('breakfast') ? 'border-primary/40 bg-primary/5 shadow-xs' : 'bg-card hover:border-border/80 opacity-80 hover:opacity-100', ) " @click="toggleUpgrade('breakfast')" @keydown.space.prevent="toggleUpgrade('breakfast')" @keydown.enter.prevent="toggleUpgrade('breakfast')" > <div class="flex flex-wrap items-start gap-3.5"> <Checkbox id="upgrade-breakfast" :model-value="selectedUpgrades.includes('breakfast')" class="pointer-events-none mt-1" tabindex="-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> <Badge wrap variant="outline" class="text-xs font-medium">+$35 / person / day</Badge> </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 --> <div v-if="selectedUpgrades.includes('breakfast')" class="flex flex-wrap items-center gap-2 pt-1.5 text-xs" @click.stop > <span class="text-muted-foreground">Breakfast plan for:</span> <div class="flex flex-wrap items-center gap-1.5"> <Button v-for="count in [1, 2]" :key="count" variant="outline" size="xs" :class="breakfastGuests === count ? 'bg-primary text-primary-foreground border-primary' : ''" @click="breakfastGuests = 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=" 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', selectedUpgrades.includes('chauffeur') ? 'border-primary/40 bg-primary/5 shadow-xs' : 'bg-card hover:border-border/80 opacity-80 hover:opacity-100', ) " @click="toggleUpgrade('chauffeur')" @keydown.space.prevent="toggleUpgrade('chauffeur')" @keydown.enter.prevent="toggleUpgrade('chauffeur')" > <div class="flex flex-wrap items-start gap-3.5"> <Checkbox id="upgrade-chauffeur" :model-value="selectedUpgrades.includes('chauffeur')" class="pointer-events-none mt-1" tabindex="-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> <Badge wrap variant="outline" class="text-xs font-medium">+$120 one-time</Badge> </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> </CardContent> </Card> <!-- Special Requests Card --> <Card class="shadow-xs"> <CardHeader class="pb-3"> <CardTitle class="text-base">Special Requests & Arrival Notes</CardTitle> <CardDescription >Let us know if you are celebrating a special occasion or require early check-in.</CardDescription > </CardHeader> <CardContent> <Input id="special-requests" v-model="specialRequests" placeholder="e.g., Honeymoon celebration, feather-free bedding, quiet high floor..." maxlength="200" show-count /> </CardContent> </Card> </div> <!-- Right Column: Total Price Breakdown & Checkout Card (Sticky) --> <div class="lg:sticky lg:top-8 lg:col-span-5"> <Card class="border-border bg-card shadow-xs"> <CardHeader 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> <Badge wrap variant="secondary" class="bg-primary/10 text-primary border-primary/20 gap-1 text-xs font-medium" > <CheckCircle2 class="size-3" /> Instant Confirmation </Badge> </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> </CardHeader> <CardContent class="space-y-4"> <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 --> <div v-if="selectedUpgrades.length > 0" class="border-border/60 bg-muted/20 space-y-1 rounded-md border p-2.5 text-xs" > <div v-if="selectedUpgrades.includes('balcony')" 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> <div v-if="selectedUpgrades.includes('breakfast')" 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> <div v-if="selectedUpgrades.includes('chauffeur')" 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> <div v-if="selectedUpgrades.includes('spa')" 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> <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> <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"> <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"> <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"> <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"> <Lock class="text-primary size-3.5 shrink-0" /> <span>256-bit encrypted secure bank checkout</span> </li> </ul> </CardContent> <CardFooter class="flex flex-col gap-2.5 pt-2"> <Button class="w-full gap-2 font-semibold shadow-xs" size="lg" @click="handleReserve"> <CreditCard class="size-4" /> <span>Reserve & Pay Now ({{ formatCurrency(totalStayPrice) }})</span> </Button> <Button variant="outline" class="w-full text-xs font-medium" size="default" @click="handleReserve"> 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> </CardFooter> </Card> </div> </div> </div> </template>
Raw manifest:https://uipkge.dev/r/vue/hotel-booking-reservation.json