Theme

Hotel Search

8 hotels found

Coastal Breeze Motel

Cape Town, South Africa
3/5

Budget-friendly ocean-view rooms just steps from the beach.

WiFiParking

Alpine Retreat Lodge

Innsbruck, Austria
4/5

Cozy mountain lodge surrounded by pristine alpine scenery.

WiFiGymRestaurantParking

Sakura Garden Inn

Kyoto, Japan
4.5/5

Traditional ryokan experience with beautifully manicured gardens.

WiFiSpaRestaurant

Midtown Loft Hotel

New York, USA
3.5/5

Modern loft-style rooms in the heart of Manhattan.

WiFiGymParking

Source

This page is rendered from the real demo file shown below.

svelte
<script lang="ts">
  import {
    Badge,
    Button,
    Card,
    Checkbox,
    Field,
    Flex,
    Grid,
    Input,
    Label,
    Pagination,
    Rating,
    Select,
    Separator,
    Slider,
    Stack,
  } from '@dryui/ui';
  import { SvelteSet } from 'svelte/reactivity';

  // --- Types ---
  type Amenity = 'WiFi' | 'Pool' | 'Spa' | 'Gym' | 'Restaurant' | 'Parking';
  type SortOption = 'price-asc' | 'price-desc' | 'rating' | 'name';

  interface Hotel {
    id: number;
    name: string;
    location: string;
    pricePerNight: number;
    rating: number;
    amenities: Amenity[];
    description: string;
  }

  // --- Mock data ---
  const allHotels: Hotel[] = [
    {
      id: 1,
      name: 'Grand Riviera Palace',
      location: 'Barcelona, Spain',
      pricePerNight: 320,
      rating: 4.5,
      amenities: ['WiFi', 'Pool', 'Spa', 'Restaurant'],
      description: 'Elegant beachfront hotel with stunning Mediterranean views.',
    },
    {
      id: 2,
      name: 'Alpine Retreat Lodge',
      location: 'Innsbruck, Austria',
      pricePerNight: 185,
      rating: 4,
      amenities: ['WiFi', 'Gym', 'Restaurant', 'Parking'],
      description: 'Cozy mountain lodge surrounded by pristine alpine scenery.',
    },
    {
      id: 3,
      name: 'The Harbour Suite',
      location: 'Sydney, Australia',
      pricePerNight: 410,
      rating: 5,
      amenities: ['WiFi', 'Pool', 'Spa', 'Gym', 'Restaurant'],
      description: 'Luxurious harbour-front suites with iconic Opera House views.',
    },
    {
      id: 4,
      name: 'Midtown Loft Hotel',
      location: 'New York, USA',
      pricePerNight: 275,
      rating: 3.5,
      amenities: ['WiFi', 'Gym', 'Parking'],
      description: 'Modern loft-style rooms in the heart of Manhattan.',
    },
    {
      id: 5,
      name: 'Sakura Garden Inn',
      location: 'Kyoto, Japan',
      pricePerNight: 230,
      rating: 4.5,
      amenities: ['WiFi', 'Spa', 'Restaurant'],
      description: 'Traditional ryokan experience with beautifully manicured gardens.',
    },
    {
      id: 6,
      name: 'Desert Dune Resort',
      location: 'Dubai, UAE',
      pricePerNight: 495,
      rating: 5,
      amenities: ['WiFi', 'Pool', 'Spa', 'Gym', 'Restaurant', 'Parking'],
      description: 'Ultra-luxury desert resort with infinity pools and private villas.',
    },
    {
      id: 7,
      name: 'Coastal Breeze Motel',
      location: 'Cape Town, South Africa',
      pricePerNight: 120,
      rating: 3,
      amenities: ['WiFi', 'Parking'],
      description: 'Budget-friendly ocean-view rooms just steps from the beach.',
    },
    {
      id: 8,
      name: 'Palazzo Venezia',
      location: 'Venice, Italy',
      pricePerNight: 360,
      rating: 4,
      amenities: ['WiFi', 'Restaurant', 'Spa'],
      description: 'Historic palazzo hotel along the Grand Canal with stunning architecture.',
    },
  ];

  const amenityOptions: Amenity[] = ['WiFi', 'Pool', 'Spa', 'Gym', 'Restaurant', 'Parking'];
  const amenityColors: Record<Amenity, 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'gray'> = {
    WiFi: 'blue',
    Pool: 'purple',
    Spa: 'green',
    Gym: 'orange',
    Restaurant: 'red',
    Parking: 'gray',
  };

  // --- Search state ---
  let destination = $state('');
  let checkIn = $state('');
  let checkOut = $state('');
  let guests = $state('2');

  // --- Filter state ---
  let maxPrice = $state(500);
  let minRating = $state(0);
  let selectedAmenities = new SvelteSet<Amenity>();
  let sortBy = $state<SortOption>('price-asc');

  // --- Pagination state ---
  let currentPage = $state(1);
  const pageSize = 4;

  function toggleAmenity(amenity: Amenity) {
    if (selectedAmenities.has(amenity)) {
      selectedAmenities.delete(amenity);
    } else {
      selectedAmenities.add(amenity);
    }
    currentPage = 1;
  }

  function handleSearch() {
    currentPage = 1;
  }

  // --- Derived: filtered + sorted hotels ---
  let filteredHotels = $derived.by(() => {
    let results = allHotels.filter((h) => {
      if (destination && !h.location.toLowerCase().includes(destination.toLowerCase()) && !h.name.toLowerCase().includes(destination.toLowerCase())) {
        return false;
      }
      if (h.pricePerNight > maxPrice) return false;
      if (h.rating < minRating) return false;
      if (selectedAmenities.size > 0) {
        for (const a of selectedAmenities) {
          if (!h.amenities.includes(a)) return false;
        }
      }
      return true;
    });

    return [...results].sort((a, b) => {
      if (sortBy === 'price-asc') return a.pricePerNight - b.pricePerNight;
      if (sortBy === 'price-desc') return b.pricePerNight - a.pricePerNight;
      if (sortBy === 'rating') return b.rating - a.rating;
      if (sortBy === 'name') return a.name.localeCompare(b.name);
      return 0;
    });
  });

  let totalPages = $derived(Math.max(1, Math.ceil(filteredHotels.length / pageSize)));

  let pagedHotels = $derived.by(() => {
    const start = (currentPage - 1) * pageSize;
    return filteredHotels.slice(start, start + pageSize);
  });
</script>

<Stack gap="lg">
  <h2>Hotel Search</h2>

  <!-- Search bar -->
  <Card.Root>
    <Card.Content>
      <Flex gap="md" align="end" wrap="wrap">
        <Field.Root class="field-destination">
          <Label>Destination</Label>
          <Input bind:value={destination} placeholder="City, region, or hotel name…" />
        </Field.Root>
        <Field.Root class="field-date">
          <Label>Check-in</Label>
          <Input type="date" bind:value={checkIn} />
        </Field.Root>
        <Field.Root class="field-date">
          <Label>Check-out</Label>
          <Input type="date" bind:value={checkOut} />
        </Field.Root>
        <Field.Root class="field-guests">
          <Label>Guests</Label>
          <Input bind:value={guests} type="number" />
        </Field.Root>
        <Button variant="solid" onclick={handleSearch}>Search</Button>
      </Flex>
    </Card.Content>
  </Card.Root>

  <!-- Body: filters + results -->
  <Flex gap="lg" align="start">
    <!-- Filters sidebar -->
    <div class="sidebar">
      <Card.Root>
        <Card.Header>
          <h3>Filters</h3>
        </Card.Header>
        <Card.Content>
          <Stack gap="lg">
            <!-- Price range -->
            <Stack gap="sm">
              <Flex justify="between" align="center">
                <Label>Max price / night</Label>
                <span class="filter-value">${maxPrice}</span>
              </Flex>
              <Slider bind:value={maxPrice} min={50} max={600} step={10} />
            </Stack>

            <Separator />

            <!-- Minimum star rating -->
            <Stack gap="sm">
              <Label>Minimum rating</Label>
              <Rating bind:value={minRating} max={5} allowHalf size="sm" />
            </Stack>

            <Separator />

            <!-- Amenities -->
            <Stack gap="sm">
              <Label>Amenities</Label>
              {#each amenityOptions as amenity (amenity)}
                <label class="amenity-label">
                  <Checkbox
                    checked={selectedAmenities.has(amenity)}
                    onclick={() => toggleAmenity(amenity)}
                    size="sm"
                  />
                  {amenity}
                </label>
              {/each}
            </Stack>

            <Separator />

            <!-- Sort -->
            <Stack gap="sm">
              <Label>Sort by</Label>
              <Select.Root bind:value={sortBy}>
                <Select.Trigger>
                  <Button variant="outline" size="sm">
                    <Select.Value placeholder="Sort by…" />
                  </Button>
                </Select.Trigger>
                <Select.Content>
                  <Select.Item value="price-asc">Price: Low to High</Select.Item>
                  <Select.Item value="price-desc">Price: High to Low</Select.Item>
                  <Select.Item value="rating">Highest Rating</Select.Item>
                  <Select.Item value="name">Name A–Z</Select.Item>
                </Select.Content>
              </Select.Root>
            </Stack>
          </Stack>
        </Card.Content>
      </Card.Root>
    </div>

    <!-- Results -->
    <Stack gap="md" class="results-pane">
      <Flex justify="between" align="center">
        <span class="results-count">
          {filteredHotels.length} hotel{filteredHotels.length !== 1 ? 's' : ''} found
        </span>
      </Flex>

      {#if pagedHotels.length === 0}
        <Card.Root>
          <Card.Content>
            <p class="no-results">No hotels match your current filters. Try adjusting your search.</p>
          </Card.Content>
        </Card.Root>
      {:else}
        <Grid columns={2} gap="md">
          {#each pagedHotels as hotel (hotel.id)}
            <Card.Root>
              <Card.Header>
                <Stack gap="sm">
                  <h4 class="hotel-name">{hotel.name}</h4>
                  <span class="hotel-location">{hotel.location}</span>
                </Stack>
              </Card.Header>
              <Card.Content>
                <Stack gap="sm">
                  <Flex gap="sm" align="center">
                    <Rating value={hotel.rating} max={5} readonly allowHalf size="sm" />
                    <span class="rating-label">{hotel.rating}/5</span>
                  </Flex>
                  <p class="hotel-desc">{hotel.description}</p>
                  <Flex gap="sm" wrap="wrap">
                    {#each hotel.amenities as amenity (amenity)}
                      <Badge variant="soft" color={amenityColors[amenity]} size="sm">{amenity}</Badge>
                    {/each}
                  </Flex>
                </Stack>
              </Card.Content>
              <Card.Footer>
                <Flex justify="between" align="center">
                  <Stack gap="sm">
                    <span class="price">${hotel.pricePerNight}</span>
                    <span class="per-night">per night</span>
                  </Stack>
                  <Button variant="solid" size="sm">Book Now</Button>
                </Flex>
              </Card.Footer>
            </Card.Root>
          {/each}
        </Grid>

        <!-- Pagination -->
        {#if totalPages > 1}
          <Flex justify="center">
            <Pagination.Root bind:page={currentPage} totalPages={totalPages}>
              <Pagination.Content>
                <Pagination.Item>
                  <Pagination.Previous>Previous</Pagination.Previous>
                </Pagination.Item>
                {#each Array.from({ length: totalPages }, (_, i) => i + 1) as p (p)}
                  <Pagination.Item>
                    <Pagination.Link page={p}>{p}</Pagination.Link>
                  </Pagination.Item>
                {/each}
                <Pagination.Item>
                  <Pagination.Next>Next</Pagination.Next>
                </Pagination.Item>
              </Pagination.Content>
            </Pagination.Root>
          </Flex>
        {/if}
      {/if}
    </Stack>
  </Flex>
</Stack>

<style>
  h2 {
    margin: 2rem 0 0.5rem;
    font-size: var(--dry-text-2xl-size);
  }

  h3 {
    margin: 0;
    font-size: var(--dry-text-base-size);
    font-weight: 600;
  }

  h4 {
    margin: 0;
  }

  .sidebar {
    width: 240px;
    flex-shrink: 0;
  }

  :global(.field-destination) {
    flex: 2;
    min-width: 180px;
  }

  :global(.field-date) {
    flex: 1;
    min-width: 140px;
  }

  :global(.field-guests) {
    min-width: 100px;
  }

  :global(.results-pane) {
    flex: 1;
    min-width: 0;
  }

  .hotel-name {
    font-size: var(--dry-text-base-size);
    font-weight: 600;
  }

  .hotel-location {
    font-size: var(--dry-text-sm-size);
    color: var(--dry-color-text-secondary);
  }

  .hotel-desc {
    margin: 0;
    font-size: var(--dry-text-sm-size);
    color: var(--dry-color-text-secondary);
    line-height: 1.5;
  }

  .rating-label {
    font-size: var(--dry-text-sm-size);
    color: var(--dry-color-text-secondary);
  }

  .price {
    font-size: var(--dry-text-xl-size);
    font-weight: 700;
    color: var(--dry-color-text);
  }

  .per-night {
    font-size: var(--dry-text-xs-size);
    color: var(--dry-color-text-secondary);
  }

  .filter-value {
    font-size: var(--dry-text-sm-size);
    font-weight: 600;
    color: var(--dry-color-text);
  }

  .amenity-label {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    font-size: var(--dry-text-sm-size);
    cursor: pointer;
  }

  .results-count {
    font-size: var(--dry-text-sm-size);
    color: var(--dry-color-text-secondary);
  }

  .no-results {
    margin: 0;
    text-align: center;
    color: var(--dry-color-text-secondary);
    padding: 2rem 0;
  }
</style>