Skip to content

Best Practices & Integration Guide

This guide covers recommended patterns for integrating with the Evocative API — from search polling to checkout, error handling, and UI implementation. All code examples use language-agnostic pseudocode unless otherwise noted.


Booking Flow Overview

The end-to-end booking flow follows this sequence:

flowchart TD
    A[Hotel Search] --> B[User Selects Hotel]
    B --> C[Room Search]
    C --> D[User Selects Room]
    D --> E[Checkout Page]
    E --> F[User Enters Details]
    F --> G[Create Order]
    G --> H{Order Created}
    H -->|Success| I[Process Payment]
    H -->|ROOM_SOLD_OUT| J[Show Sold Out Message]
    H -->|Price Changed| K[Show Price Update]
    I --> L[Update Payment Status]
    L --> M[Show Confirmation]
    J --> N[Return to Room Selection]
    K --> O{User Accepts}
    O -->|Yes| I
    O -->|No| N

Preferred Search Flow

The search portion of the booking flow uses a two-step approach:

  1. List levelGET /api/v2/search returns properties with pricing overview.
  2. Detailed property levelGET /api/v2/properties/{id}/packages returns full room and package details for a selected property.

This keeps the initial search response lightweight and defers heavier per-property detail loading to when the user actually needs it.

Session Continuity

The booking flow depends on maintaining session continuity through identifiers returned at each step.

Step Required Data
Hotel Search Initial search parameters
Quote packageId from search response
Book packageBookingId from quote response
Payment reservationId from order response

Critical: Data Preservation

Store all required identifiers (packageId, packageBookingId) as users navigate through the booking flow. Loss of any identifier will require restarting the search.


Search Polling

Smart Polling Implementation

For optimal performance and user experience, implement these polling enhancements:

FUNCTION pollSearch(searchParams):
    MAX_POLLS = 20              // Safety limit (~40 seconds)
    POLL_INTERVAL = 2000        // 2 seconds

    pollCount = 0

    WHILE pollCount < MAX_POLLS:
        response = CALL GET /api/v2/search WITH searchParams

        // Stop when search is complete
        IF response.isDone == true:
            RETURN response.hotels

        // Pause polling when browser tab is hidden
        IF document.hidden:
            WAIT until tab is visible

        // Pause when user is interacting with map/filters
        IF userIsInteracting:
            PAUSE polling

        pollCount = pollCount + 1
        WAIT POLL_INTERVAL
    END WHILE

    // Max polls reached - show partial results
    RETURN response.hotels WITH warning
END FUNCTION

Polling Best Practices

Practice Recommendation Reason
Max timeout Set 40–60 second limit Prevents infinite polling
Tab visibility Pause when hidden Reduces unnecessary API calls
Request cancellation Cancel on navigation Prevents stale data
Cleanup Cancel on component unmount Prevents memory leaks

Request Cancellation

Always implement cancellation to prevent stale requests:

// Using AbortController (JavaScript/TypeScript)
const controller = new AbortController();

fetch('/api/v2/search?' + params, { signal: controller.signal });

// Cancel when user navigates away
window.addEventListener('beforeunload', () => controller.abort());

Performance Optimization

Pausing polling when the browser tab is hidden can reduce API calls by 30–50% for users who switch tabs while waiting for results.

Progressive Results Loading

Don't wait for isDone=true to display results. Show hotels as they arrive:

FUNCTION handleSearchResponse(response, currentHotels):
    // Merge new results with existing
    updatedHotels = mergeHotels(currentHotels, response.hotels)

    // Sort to maintain order
    sortedHotels = sortBy(updatedHotels, currentSortCriteria)

    // Update UI immediately
    RENDER hotels with shimmer effect

    // Show loading indicator
    IF NOT response.isDone:
        SHOW "Finding better prices..." indicator
    ELSE:
        HIDE loading indicator
        REMOVE shimmer effects
END FUNCTION

Discount Validation

The discount field in hotel responses represents the percentage discount from the market price. While most values are accurate, implement validation to handle edge cases.

Validation Rules

FUNCTION validateDiscount(discount, marketPrice, price):
    // Rule 1: Filter extreme outliers (data anomalies)
    IF discount > 70:
        RETURN null  // Don't display this result or show without discount

    // Rule 2: Cap displayed discount at 60%
    IF discount > 60:
        displayDiscount = "60%+"
    ELSE:
        displayDiscount = discount + "%"

    // Rule 3: Verify discount matches price difference
    calculatedDiscount = ((marketPrice - price) / marketPrice) * 100
    IF ABS(calculatedDiscount - discount) > 5:
        LOG warning "Discount mismatch for hotel"

    RETURN displayDiscount
END FUNCTION

Why These Thresholds?

Threshold Action Reason
> 70% Filter out Likely data anomaly from supplier
> 60% Cap display Prevents unrealistic expectations
Mismatch > 5% Log warning Helps identify data quality issues

Discount Anomalies

Discounts above 70% typically indicate supplier data errors rather than genuine deals. Displaying these can lead to customer complaints when the actual savings don't match expectations.

Display Recommendations

IF discount > 0 AND discount <= 60:
    Show: "Save {discount}%"
    Show strikethrough market price

ELSE IF discount > 60 AND discount <= 70:
    Show: "Save 60%+"
    Show strikethrough market price

ELSE IF discount > 70:
    // Don't show discount badge
    // Optionally hide market price
    Show only the current price

ELSE:
    // No discount or invalid
    Show only the current price

Market Price Display

When showing the crossed-out market price (marketPrice), consider adding a tooltip explaining: "Compared to typical rates on other travel sites" to set proper expectations.


Checkout & Order Creation

Handling Order Creation Responses

FUNCTION handleOrderResponse(response):
    IF response.success == true:
        // Order created successfully
        STORE response.data.reservationId
        STORE response.expiresAt
        START payment timer (expiresAt - now)
        PROCEED to payment

    ELSE IF response.name == "ROOM_SOLD_OUT":
        SHOW "This room is no longer available"
        OFFER alternative rooms or return to search

    ELSE IF response.name == "PROPERTY_NOT_FOUND":
        SHOW "Hotel information has changed"
        REDIRECT to hotel search

    ELSE IF response.name == "OCCUPANCY_MISMATCH":
        SHOW "Please provide guest details for all rooms"
        HIGHLIGHT missing guest fields

    ELSE:
        LOG error for investigation
        SHOW "Unable to complete booking. Please try again."
END FUNCTION

Preventing Duplicate Orders

Use the customerOrderId field to prevent duplicate bookings. If an order with the same customerOrderId already exists, the API returns DUPLICATE_CUSTOMER_ORDER_ID instead of creating a duplicate.

FUNCTION createOrderSafely(orderData):
    // Generate a unique order ID for this booking attempt
    customerOrderId = generateUniqueId()  // e.g., UUID or your internal order ID
    orderData.customerOrderId = customerOrderId

    // Prevent double-submission on UI level
    IF orderInProgress:
        RETURN  // Ignore duplicate clicks

    orderInProgress = true
    DISABLE submit button
    SHOW loading state

    TRY:
        response = CALL POST /api/order WITH orderData

        IF response.success:
            STORE response.data.reservationId
            PROCEED to payment

        ELSE IF response.name == "DUPLICATE_CUSTOMER_ORDER_ID":
            // Order already exists - safe to retry
            SHOW "Order already created"
            FETCH existing order status

        ELSE:
            HANDLE error response

    CATCH networkError:
        // Safe to retry with same customerOrderId
        SHOW "Connection error. Click to retry."
        // Retrying will either succeed or return DUPLICATE_CUSTOMER_ORDER_ID

    FINALLY:
        orderInProgress = false
        ENABLE submit button
END FUNCTION

Idempotency with customerOrderId

By including a unique customerOrderId with each booking request, you can safely retry failed requests without risk of creating duplicate bookings. The same customerOrderId will always map to the same order. It is an optional string that must be unique (or null); use a collision-proof value such as a prefix + GUID (e.g. <company_prefix>_<guid>).

Handling Room Unavailability

No Pre-Booking Check

The API does not provide a pre-booking validation endpoint. Room availability and pricing are confirmed only when creating an order. If a room becomes unavailable between search and checkout, the order creation will return a ROOM_SOLD_OUT error.

When order creation fails with ROOM_SOLD_OUT, the room is no longer available at the originally displayed price. This can occur due to:

  • Room inventory exhausted
  • Dynamic pricing changes
  • Supplier rate expiration

Sold Out Recovery

When a room becomes unavailable, provide a smooth recovery experience:

FUNCTION handleSoldOut(evocativeId, originalRoom):
    // Fetch alternative rooms
    alternatives = CALL /api/v2/search WITH evocativeId

    IF alternatives.length > 0:
        // Find similar rooms
        similarRooms = FILTER alternatives WHERE:
            - sameRoomType OR
            - priceDifference < 20% OR
            - sameCancellationPolicy

        IF similarRooms.length > 0:
            SHOW "Your room is no longer available. Here are similar options:"
            DISPLAY similarRooms
        ELSE:
            SHOW "Your room is no longer available. Other rooms at this hotel:"
            DISPLAY alternatives

    ELSE:
        SHOW "No rooms available at this hotel"
        OFFER to return to hotel search
END FUNCTION

Payment Handling

Payment Timer Management

Orders expire if payment isn't completed within the time limit (typically 20 minutes). Implement a countdown timer to keep users informed.

FUNCTION startPaymentTimer(expiresAt):
    remainingTime = expiresAt - currentTime

    WHILE remainingTime > 0:
        DISPLAY formatTime(remainingTime)

        // Warning thresholds
        IF remainingTime <= 60 seconds:
            SHOW urgent warning "Less than 1 minute remaining!"
            HIGHLIGHT timer in red

        ELSE IF remainingTime <= 300 seconds:
            SHOW warning "5 minutes remaining"
            HIGHLIGHT timer in orange

        WAIT 1 second
        remainingTime = remainingTime - 1

    // Timer expired
    SHOW "Your reservation has expired"
    DISABLE payment button
    OFFER to start new booking
END FUNCTION

Payment Expiration

Always display the remaining time to users. If the timer expires before payment is confirmed, the reservation is automatically released and users must start over.

Confirmation Best Practices

After successful payment confirmation:

  1. Display confirmation immediately — Show reservationId and booking details
  2. Send confirmation email — Include all booking details and hotel contact
  3. Provide save/print option — Allow users to save confirmation offline
  4. Show next steps — Check-in time, hotel address, cancellation policy
FUNCTION showConfirmation(orderStatus):
    DISPLAY:
        - Booking reference: orderStatus.bookingReference
        - Hotel: orderStatus.hotelName
        - Address: orderStatus.hotelAddress
        - Check-in: formatDate(orderStatus.checkInDate)
        - Check-out: formatDate(orderStatus.checkOutDate)
        - Room: orderStatus.roomName
        - Total paid: formatCurrency(orderStatus.price)
        - Cancellation policy summary

    OFFER:
        - "Add to Calendar" button
        - "Print Confirmation" button
        - "Email Confirmation" button
END FUNCTION

Error Handling & Recovery

Error Code Reference

Code Error Cause Recovery Action
400 Bad Request Invalid parameters or malformed request Validate input before request; show user-friendly error
401 Unauthorized Missing or invalid API key Check API key configuration
404 Not Found Resource not found Check request parameters
429 Too Many Requests Rate limit exceeded Implement exponential backoff; retry after delay
500 Server Error Internal server error Retry with backoff; show error after 3 attempts
502 Bad Gateway Request timed out or upstream failure Search: re-issue with identical parameters to recover the in-progress result (retained 5 min). Order/booking: retry safely using the same customerOrderId idempotency key (see Preventing Duplicate Orders). Then backoff
503 Service Unavailable Service temporarily down Retry with backoff; show maintenance message

Retry Logic with Exponential Backoff

FUNCTION fetchWithRetry(url, options, maxRetries = 3):
    retryCount = 0
    baseDelay = 1000  // 1 second

    WHILE retryCount < maxRetries:
        TRY:
            response = FETCH url WITH options

            IF response.status == 200:
                RETURN response.data

            // Don't retry client errors (except rate limiting)
            IF response.status >= 400 AND response.status < 500:
                IF response.status == 429:
                    // Rate limited - use longer delay
                    delay = getRetryAfterHeader(response) OR (baseDelay * 4)
                    WAIT delay
                    retryCount = retryCount + 1
                    CONTINUE
                ELSE:
                    THROW ClientError(response.status, response.body)

            // Server errors - retry with backoff
            IF response.status >= 500:
                delay = baseDelay * (2 ^ retryCount)  // Exponential: 1s, 2s, 4s
                WAIT delay
                retryCount = retryCount + 1

        CATCH networkError:
            // Network failures - retry with backoff
            delay = baseDelay * (2 ^ retryCount)
            WAIT delay
            retryCount = retryCount + 1

    END WHILE

    THROW MaxRetriesExceeded
END FUNCTION

Handling Specific Scenarios

Rate Limiting (429)

FUNCTION handleRateLimit(response):
    // Check for Retry-After header
    retryAfter = response.headers["Retry-After"]

    IF retryAfter EXISTS:
        WAIT retryAfter seconds
    ELSE:
        WAIT 5 seconds  // Default backoff

    // Reduce polling frequency temporarily
    INCREASE pollInterval BY 50%

    RETRY request
END FUNCTION

Rate Limit Prevention

To avoid hitting rate limits:

  • Use 2-second polling intervals (not faster)
  • Pause polling when browser tab is hidden
  • Cancel requests when user navigates away
  • Don't start new searches while one is in progress

Handling Expired Sessions

When a booking session expires, the API automatically performs a new search with the same parameters. If the original room offer cannot be found, you'll receive a ROOM_SOLD_OUT error at order creation time.

flowchart TD
    A[Create Order Request] --> B{Session Valid}
    B -->|Yes| C[Process Order]
    B -->|No| D[Auto Re-search]
    D --> E{Room Found}
    E -->|Yes| C
    E -->|No| F[Return ROOM_SOLD_OUT]
    F --> G[Offer Alternative Rooms]

No Explicit 404 for Sessions

The API does not return a 404 error specifically for expired sessions. Handle ROOM_SOLD_OUT errors by offering alternative rooms to the user.

Gateway Errors (502/503)

FUNCTION handleGatewayError(error, currentPoll):
    IF currentPoll.retryCount < 2:
        // Quick retry for transient errors
        WAIT 500ms
        RETRY request
    ELSE:
        // Show partial results if available
        IF currentPoll.hasPartialResults:
            SHOW results WITH warning "Some results may be missing"
        ELSE:
            SHOW error "Unable to load results. Please try again."
END FUNCTION

Order Error Recovery

FUNCTION handleOrderError(error, context):
    SWITCH error.status:
        CASE 400:
            // Bad request - validation error
            SHOW error.message
            HIGHLIGHT invalid fields

        CASE 404:
            // Session expired or resource not found
            IF context.hasEs:
                SHOW "Your session has expired"
                REDIRECT to search with preserved criteria
            ELSE:
                SHOW "Booking information not found"
                REDIRECT to home

        CASE 429:
            // Rate limited
            WAIT 5 seconds
            RETRY with backoff

        CASE 500, 502, 503:
            // Server error
            IF retryCount < 2:
                WAIT 2 seconds
                RETRY
            ELSE:
                SHOW "Service temporarily unavailable"
                OFFER manual retry button

        DEFAULT:
            LOG error
            SHOW "Something went wrong. Please try again."
END FUNCTION

User-Facing Error Messages

Error Type User Message Action Button
Network error "Connection lost. Please check your internet." "Retry"
Rate limited "Too many requests. Please wait a moment." Auto-retry (hidden)
Session expired "Your session has expired." "Search Again"
Server error "Something went wrong. Please try again." "Retry"
No results "No hotels found matching your criteria." "Modify Search"

Error Recovery UX

Always preserve the user's search criteria when redirecting after errors. This allows them to quickly retry without re-entering dates, guests, and destination.


UI Patterns

Loading States

During search polling, prices arrive progressively. Use visual states to communicate loading progress:

stateDiagram-v2
    [*] --> Skeleton: Search starts
    Skeleton --> Shimmer: First results arrive
    Shimmer --> FinalPrice: isFinished=true
    Shimmer --> Shimmer: New prices arrive
    FinalPrice --> [*]
State When Visual Treatment
Skeleton No data yet Gray placeholder blocks
Shimmer Partial data, still polling Show price with animated shimmer overlay
Final isDone=true Solid price, no animation

Skeleton Implementation

Display placeholder content while waiting for initial results:

COMPONENT PriceSkeleton:
    RENDER:
        <div class="skeleton">
            <div class="skeleton-line" width="60px" />  // Price
            <div class="skeleton-line" width="40px" />  // Per night
            <div class="skeleton-line" width="80px" />  // Taxes text
        </div>

CSS Pattern:

.skeleton {
    background: linear-gradient(
        90deg,
        #e0e0e0 25%,
        #f0f0f0 50%,
        #e0e0e0 75%
    );
    background-size: 200% 100%;
    animation: skeleton-pulse 1.5s ease-in-out infinite;
    border-radius: 4px;
}

@keyframes skeleton-pulse {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
}

Shimmer Overlay

When prices are loaded but search is still polling, add a shimmer effect to indicate potential updates:

.price-shimmer {
    position: relative;
    overflow: hidden;
}

.price-shimmer::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: linear-gradient(
        90deg,
        transparent 0%,
        rgba(255, 255, 255, 0.4) 50%,
        transparent 100%
    );
    animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
    0% { transform: translateX(-100%); }
    100% { transform: translateX(100%); }
}

Implementation Pattern

FUNCTION renderPrice(hotel, isFinished):
    IF hotel.price == null:
        RETURN <PriceSkeleton />

    IF NOT isFinished:
        RETURN <PriceDisplay class="shimmer" price={hotel.price} />

    RETURN <PriceDisplay price={hotel.price} />
END FUNCTION

Button States

stateDiagram-v2
    [*] --> Disabled: Form incomplete
    Disabled --> Ready: Form valid
    Ready --> Loading: User clicks
    Loading --> Ready: Search complete
    Loading --> Error: Search failed
    Error --> Ready: User corrects
State Appearance Behavior
Disabled Grayed out, reduced opacity No click response
Ready Primary color, full opacity Clickable
Loading Primary color + spinner Click disabled, shows progress
Error Red/warning color Shows error message
.button {
    padding: 12px 24px;
    border-radius: 8px;
    font-weight: 600;
    transition: all 0.2s ease;
    cursor: pointer;
}

.button--disabled {
    background: #ccc;
    color: #666;
    cursor: not-allowed;
    opacity: 0.6;
}

.button--ready {
    background: #007bff;
    color: white;
}

.button--ready:hover {
    background: #0056b3;
    transform: translateY(-1px);
}

.button--loading {
    background: #007bff;
    color: white;
    cursor: wait;
    pointer-events: none;
}

.button--loading::after {
    content: '';
    width: 16px;
    height: 16px;
    border: 2px solid white;
    border-top-color: transparent;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin-left: 8px;
    display: inline-block;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

Checkout Button Logic

FUNCTION renderCheckoutButton(formState, orderState):
    IF NOT formState.isValid:
        RETURN <Button disabled>Complete required fields</Button>

    IF orderState.isSubmitting:
        RETURN <Button loading>Processing...</Button>

    IF orderState.error:
        RETURN <Button error onClick={retry}>Try Again</Button>

    RETURN <Button ready onClick={submit}>Complete Booking</Button>
END FUNCTION

Price Display

COMPONENT PriceDisplay:
    PROPS:
        - totalPrice: number
        - pricePerNight: number
        - marketPrice: number (optional)
        - discount: number (optional)
        - nights: number
        - showTaxesIncluded: boolean

    RENDER:
        <div class="price-container">
            // Market price (crossed out)
            IF marketPrice AND discount > 0 AND discount <= 70:
                <span class="market-price">{formatCurrency(marketPrice)}</span>

            // Current price
            <span class="current-price">{formatCurrency(totalPrice)}</span>

            // Discount badge
            IF discount > 0 AND discount <= 60:
                <span class="discount-badge">Save {discount}%</span>
            ELSE IF discount > 60 AND discount <= 70:
                <span class="discount-badge">Save 60%+</span>

            // Per night breakdown
            IF nights > 1:
                <span class="per-night">
                    {formatCurrency(pricePerNight)} / night
                </span>

            // Tax indicator
            IF showTaxesIncluded:
                <span class="taxes-included">
                    <CheckIcon /> Taxes included
                </span>
        </div>
.price-container {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 4px;
}

.market-price {
    color: #666;
    text-decoration: line-through;
    font-size: 14px;
}

.current-price {
    color: #1a1a1a;
    font-size: 24px;
    font-weight: 600;
}

.discount-badge {
    background: #e8f5e9;
    color: #2e7d32;
    padding: 2px 8px;
    border-radius: 4px;
    font-size: 12px;
    font-weight: 500;
}

.per-night {
    color: #666;
    font-size: 13px;
}

.taxes-included {
    color: #2e7d32;
    font-size: 12px;
    display: flex;
    align-items: center;
    gap: 4px;
}

Error Display

Error Type Display Location Style
Form validation Below input field Inline, red text
API error Toast/banner Dismissible notification
Session expired Modal dialog Blocking, requires action
Network error Toast + retry Non-blocking with action
.error-inline {
    color: #d32f2f;
    font-size: 12px;
    display: flex;
    align-items: center;
    gap: 4px;
    margin-top: 4px;
}

.error-toast {
    position: fixed;
    bottom: 20px;
    right: 20px;
    background: #ffebee;
    border: 1px solid #ef9a9a;
    border-radius: 8px;
    padding: 12px 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    animation: slide-in 0.3s ease;
}

@keyframes slide-in {
    from {
        transform: translateX(100%);
        opacity: 0;
    }
    to {
        transform: translateX(0);
        opacity: 1;
    }
}

Form Validation

Validate fields as users type, but delay error display:

FUNCTION handleFieldChange(field, value):
    // Validate immediately
    validationResult = validate(field, value)

    // Store validation state
    SET fieldState.isValid = validationResult.isValid
    SET fieldState.error = validationResult.error

    // Delay showing error (don't interrupt typing)
    IF NOT validationResult.isValid:
        DEBOUNCE 500ms:
            IF field.hasBeenBlurred:
                SHOW error message
    ELSE:
        HIDE error message immediately
END FUNCTION

Guest Details Validation

VALIDATION_RULES:
    firstName:
        - required: "First name is required"
        - maxLength(50): "Maximum 50 characters"

    lastName:
        - required: "Last name is required"
        - maxLength(50): "Maximum 50 characters"

    email:
        - required: "Email is required"
        - email: "Please enter a valid email"
        - maxLength(200): "Maximum 200 characters"

    phone:
        - required: "Phone number is required"
        - pattern(/^\+?[\d\s-]+$/): "Please enter a valid phone number"

Payment Timer UI

COMPONENT PaymentTimer:
    PROPS:
        - expiresAt: timestamp

    STATE:
        - remainingSeconds: number
        - urgency: 'normal' | 'warning' | 'critical'

    ON_MOUNT:
        START interval every 1 second:
            remainingSeconds = calculateRemaining(expiresAt)

            IF remainingSeconds <= 0:
                urgency = 'expired'
                TRIGGER onExpired callback
            ELSE IF remainingSeconds <= 60:
                urgency = 'critical'
            ELSE IF remainingSeconds <= 300:
                urgency = 'warning'
            ELSE:
                urgency = 'normal'

    RENDER:
        <div class="timer timer--{urgency}">
            <ClockIcon />
            <span>
                {formatTime(remainingSeconds)} remaining
            </span>
        </div>
.timer {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    border-radius: 4px;
    font-size: 14px;
    font-weight: 500;
}

.timer--normal {
    background: #e3f2fd;
    color: #1565c0;
}

.timer--warning {
    background: #fff3e0;
    color: #e65100;
    animation: pulse 2s infinite;
}

.timer--critical {
    background: #ffebee;
    color: #c62828;
    animation: pulse 1s infinite;
}

.timer--expired {
    background: #f5f5f5;
    color: #666;
}

@keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.7; }
}

Mobile Considerations

Touch-Friendly Targets

Ensure interactive elements are easily tappable:

/* Minimum touch target size: 44x44px */
.button,
.card-clickable,
.input {
    min-height: 44px;
    min-width: 44px;
}

/* Add padding for small text links */
.link {
    padding: 8px;
    margin: -8px;
}

Responsive Price Display

.price-container {
    /* Desktop */
    align-items: flex-end;
    text-align: right;
}

@media (max-width: 768px) {
    .price-container {
        /* Mobile */
        align-items: flex-start;
        text-align: left;
    }

    .current-price {
        font-size: 20px;
    }

    .market-price,
    .per-night {
        font-size: 12px;
    }
}

Accessibility

Screen Reader Announcements

Announce dynamic content changes:

FUNCTION announceToScreenReader(message):
    // Create or reuse live region
    liveRegion = document.getElementById('sr-announcements')

    IF NOT liveRegion:
        liveRegion = CREATE element 'div'
        SET aria-live = "polite"
        SET aria-atomic = "true"
        SET class = "sr-only"
        APPEND to body

    // Update content (triggers announcement)
    liveRegion.textContent = message
END FUNCTION

// Usage
ON search complete:
    announceToScreenReader("{count} hotels found")

ON price update:
    announceToScreenReader("Price updated to {price}")

ON error:
    announceToScreenReader("Error: {message}")

Focus Management

FUNCTION handleModalOpen(modalElement):
    // Store current focus
    previousFocus = document.activeElement

    // Move focus to modal
    modalElement.focus()

    // Trap focus within modal
    ENABLE focus trap

ON modal close:
    // Restore focus
    previousFocus.focus()

Screen Reader Only CSS

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

Integration Checklist

Before launching your booking integration, verify:

  • Search polling: 2-second intervals with max timeout and tab visibility handling
  • Progressive loading: Results appear as they arrive, not only after isFinished
  • Discount validation: Outliers filtered (>70%), display capped (>60%)
  • Session continuity: All identifiers (packageId, packageBookingId) preserved across steps
  • Duplicate prevention: customerOrderId included in all order requests
  • Payment timer: Countdown visible with urgency indicators
  • Error handling: Retry logic with exponential backoff, user-friendly messages
  • Sold out recovery: Alternative rooms offered when ROOM_SOLD_OUT occurs
  • Loading states: Skeleton → Shimmer → Final transitions work smoothly
  • Button states: Disabled, loading, error states are visually distinct
  • Form validation: Real-time feedback without interrupting users
  • Mobile: Touch targets are 44px+, layout adapts to small screens
  • Accessibility: Screen readers announce dynamic changes