Repository: kiwicom/mobile
Pull request #1343 (2018-11-20 09:31:47)

Hotels: Show offline screen for all hotels search results screen
Some issues/discoveries
• We might need to render different offline screens (since close does not make sense for non modal screens)
• the fixed position close button needs to be rendered inside the queryrenderer, if not it will render on top of offline screen buttons, or be missing on other error screens. Meaning we will not be able to close the modal on errors, which is bad UX.

Therefore a renderOfflineScreen passing the retry functions was the best solution I found. Also passing the footer component is a result of the above. 

Let me know if you can think of any improvements. 


Patch:
diff --git a/app/hotels/src/HotelsContext.js b/app/hotels/src/HotelsContext.js
index a1b93fa4..b6f822ad 100644
--- a/app/hotels/src/HotelsContext.js
+++ b/app/hotels/src/HotelsContext.js
@@ -28,6 +28,7 @@ const defaultState = {
   getGuestCount: () => 0,
   setHotelId: () => {},
   setPaymentLink: () => {},
+  closeHotels: () => {},
 };
 
 const { Consumer, Provider: ContextProvider } = React.createContext<State>({
@@ -56,6 +57,7 @@ type Props = {|
   +longitude: ?number,
   +hotelId: ?string,
   +apiProvider: ApiProvider,
+  +closeHotels: () => void,
 |};
 
 type State = {|
@@ -75,6 +77,7 @@ type State = {|
   +setPaymentLink: (?string) => void,
   +getGuestCount: () => number,
   +setHotelId: (hotelId: string) => void,
+  +closeHotels: () => void,
   +actions: {|
     +setCurrentSearchStats: ({|
       priceMax: number,
@@ -120,6 +123,7 @@ class Provider extends React.Component<Props, State> {
       getGuestCount: this.getGuestCount,
       setHotelId: this.setHotelId,
       setPaymentLink: this.setPaymentLink,
+      closeHotels: props.closeHotels,
       actions: {
         setCurrentSearchStats: this.setCurrentSearchStats,
       },
diff --git a/app/hotels/src/allHotels/HotelsPaginationContainer.js b/app/hotels/src/allHotels/HotelsPaginationContainer.js
index 6b68a171..f401a073 100644
--- a/app/hotels/src/allHotels/HotelsPaginationContainer.js
+++ b/app/hotels/src/allHotels/HotelsPaginationContainer.js
@@ -1,17 +1,20 @@
 // @flow strict
 
 import * as React from 'react';
+import { View } from 'react-native';
 import {
   graphql,
   createPaginationContainer,
   type RelayPaginationProp,
 } from '@kiwicom/mobile-relay';
-import { Logger } from '@kiwicom/mobile-shared';
+import { Logger, StyleSheet } from '@kiwicom/mobile-shared';
+import { defaultTokens } from '@kiwicom/mobile-orbit';
 
 import type { HotelsPaginationContainer as HotelsPaginationContainerType } from './__generated__/HotelsPaginationContainer.graphql';
 import { withHotelsContext, type HotelsContextState } from '../HotelsContext';
 import type { CurrentSearchStats } from '../filter/CurrentSearchStatsType';
 import RenderSearchResults from './RenderSearchResults';
+import FilterStripe from '../filter/FilterStripe';
 
 type PropsWithContext = {|
   ...Props,
@@ -68,13 +71,18 @@ export class HotelsPaginationContainer extends React.Component<
     const data = edges.map(hotel => hotel?.node);
 
     return (
-      <RenderSearchResults
-        onLoadMore={this.loadMore}
-        hasMore={this.props.relay.hasMore()}
-        isLoading={this.state.isLoading}
-        data={data}
-        top={56}
-      />
+      <React.Fragment>
+        <View style={styles.filterContainer}>
+          <FilterStripe />
+        </View>
+        <RenderSearchResults
+          onLoadMore={this.loadMore}
+          hasMore={this.props.relay.hasMore()}
+          isLoading={this.state.isLoading}
+          data={data}
+          top={56}
+        />
+      </React.Fragment>
     );
   }
 }
@@ -88,6 +96,12 @@ const select = ({ actions }: HotelsContextState) => ({
   setCurrentSearchStats: actions.setCurrentSearchStats,
 });
 
+const styles = StyleSheet.create({
+  filterContainer: {
+    zIndex: parseInt(defaultTokens.zIndexSticky),
+  },
+});
+
 export default createPaginationContainer(
   withHotelsContext(select)(HotelsPaginationContainer),
   graphql`
diff --git a/app/hotels/src/allHotels/HotelsSearch.js b/app/hotels/src/allHotels/HotelsSearch.js
new file mode 100644
index 00000000..2521b982
--- /dev/null
+++ b/app/hotels/src/allHotels/HotelsSearch.js
@@ -0,0 +1,45 @@
+// @flow
+
+import * as React from 'react';
+import { GeneralError, OfflineScreen } from '@kiwicom/mobile-shared';
+import { Translation } from '@kiwicom/mobile-localization';
+import { PublicApiRenderer } from '@kiwicom/mobile-relay';
+
+import CloseModal from '../components/CloseModal';
+
+type Props = {|
+  +onClose: () => void,
+  +query: string,
+  +variables: Object,
+  +render: (props: Object) => React.Node,
+  +shouldRenderDateError: boolean,
+|};
+
+export default class HotelsSearch extends React.Component<Props> {
+  renderOfflineScreen = (retry: () => void) => {
+    return <OfflineScreen onClose={this.props.onClose} onTryAgain={retry} />;
+  };
+
+  render() {
+    if (this.props.shouldRenderDateError) {
+      return (
+        <GeneralError
+          testID="render-error"
+          errorMessage={
+            <Translation id="hotels_search.all_hotels_search.date_error" />
+          }
+        />
+      );
+    }
+
+    return (
+      <PublicApiRenderer
+        renderOfflineScreen={this.renderOfflineScreen}
+        footer={<CloseModal onPress={this.props.onClose} />}
+        query={this.props.query}
+        variables={this.props.variables}
+        render={this.props.render}
+      />
+    );
+  }
+}
diff --git a/app/hotels/src/allHotels/NewAllHotels.js b/app/hotels/src/allHotels/NewAllHotels.js
index c36c85f7..ecbe8a4f 100644
--- a/app/hotels/src/allHotels/NewAllHotels.js
+++ b/app/hotels/src/allHotels/NewAllHotels.js
@@ -3,9 +3,7 @@
 import * as React from 'react';
 import { View } from 'react-native';
 import { StyleSheet } from '@kiwicom/mobile-shared';
-import { defaultTokens } from '@kiwicom/mobile-orbit';
 
-import FilterStripe from '../filter/FilterStripe';
 import NewAllHotelsSearch from './NewAllHotelsSearch';
 import { withHotelsContext, type HotelsContextState } from '../HotelsContext';
 import Stay22HotelsSearch from './Stay22HotelsSearch';
@@ -17,11 +15,6 @@ type Props = {|
 const NewAllHotels = ({ cityId }: Props) => (
   <View style={styles.container}>
     <View style={styles.absoluteFill}>
-      {cityId !== null && (
-        <View style={styles.filterContainer}>
-          <FilterStripe />
-        </View>
-      )}
       {cityId === null ? <Stay22HotelsSearch /> : <NewAllHotelsSearch />}
     </View>
   </View>
@@ -32,9 +25,6 @@ const styles = StyleSheet.create({
     flex: 1,
   },
   absoluteFill: StyleSheet.absoluteFillObject,
-  filterContainer: {
-    zIndex: parseInt(defaultTokens.zIndexSticky),
-  },
 });
 
 const select = ({ cityId }: HotelsContextState) => ({ cityId });
diff --git a/app/hotels/src/allHotels/NewAllHotelsSearch.js b/app/hotels/src/allHotels/NewAllHotelsSearch.js
index d87f4587..37d67f4b 100644
--- a/app/hotels/src/allHotels/NewAllHotelsSearch.js
+++ b/app/hotels/src/allHotels/NewAllHotelsSearch.js
@@ -1,9 +1,8 @@
 // @flow strict
 
 import * as React from 'react';
-import { graphql, PublicApiRenderer } from '@kiwicom/mobile-relay';
-import { DateFormatter, Translation } from '@kiwicom/mobile-localization';
-import { GeneralError } from '@kiwicom/mobile-shared';
+import { graphql } from '@kiwicom/mobile-relay';
+import { DateFormatter } from '@kiwicom/mobile-localization';
 
 import {
   withHotelsFilterContext,
@@ -17,6 +16,7 @@ import {
 import { sanitizeHotelFacilities } from '../GraphQLSanitizers';
 import type { NewAllHotelsSearchQueryResponse } from './__generated__/NewAllHotelsSearchQuery.graphql';
 import HotelsPaginationContainer from './HotelsPaginationContainer';
+import HotelsSearch from './HotelsSearch';
 
 type Props = {|
   +checkin: Date | null,
@@ -26,8 +26,24 @@ type Props = {|
   +cityId: string | null,
   +orderBy: $PropertyType<HotelsFilterState, 'orderBy'>,
   +filterParams: $PropertyType<HotelsFilterState, 'filterParams'>,
+  +renderOfflineScreen: (retry: () => void) => React.Node,
+  +onClose: () => void,
+  +renderDateError: () => React.Node,
+  +renderFooter: () => React.Node,
 |};
 
+const query = graphql`
+  query NewAllHotelsSearchQuery(
+    $search: HotelsSearchInput!
+    $filter: HotelsFilterInput!
+    $options: AvailableHotelOptionsInput
+    $first: Int
+    $after: String
+  ) {
+    ...HotelsPaginationContainer
+  }
+`;
+
 class NewAllHotelsSearch extends React.Component<Props> {
   renderAllHotelsSearchList = (
     propsFromRenderer: NewAllHotelsSearchQueryResponse,
@@ -45,33 +61,17 @@ class NewAllHotelsSearch extends React.Component<Props> {
       orderBy,
       filterParams,
     } = this.props;
-    if (checkin === null || checkout === null) {
-      return (
-        <GeneralError
-          errorMessage={
-            <Translation id="hotels_search.all_hotels_search.date_error" />
-          }
-        />
-      );
-    }
+
     return (
-      <PublicApiRenderer
-        query={graphql`
-          query NewAllHotelsSearchQuery(
-            $search: HotelsSearchInput!
-            $filter: HotelsFilterInput!
-            $options: AvailableHotelOptionsInput
-            $first: Int
-            $after: String
-          ) {
-            ...HotelsPaginationContainer
-          }
-        `}
+      <HotelsSearch
+        shouldRenderDateError={checkin === null || checkout === null}
+        onClose={this.props.onClose}
+        query={query}
         variables={{
           search: {
             cityId,
-            checkin: DateFormatter(checkin).formatForMachine(),
-            checkout: DateFormatter(checkout).formatForMachine(),
+            checkin: DateFormatter(checkin ?? new Date()).formatForMachine(),
+            checkout: DateFormatter(checkout ?? new Date()).formatForMachine(),
             roomsConfiguration,
           },
           filter: {
@@ -106,6 +106,7 @@ const selectHotelsContext = (state: HotelsContextState) => ({
   currency: state.currency,
   roomsConfiguration: state.roomsConfiguration,
   cityId: state.cityId,
+  onClose: state.closeHotels,
 });
 
 export default withHotelsContext(selectHotelsContext)(
diff --git a/app/hotels/src/allHotels/Stay22HotelsSearch.js b/app/hotels/src/allHotels/Stay22HotelsSearch.js
index c6f2d209..fe883ee0 100644
--- a/app/hotels/src/allHotels/Stay22HotelsSearch.js
+++ b/app/hotels/src/allHotels/Stay22HotelsSearch.js
@@ -1,13 +1,13 @@
 // @flow strict
 
 import * as React from 'react';
-import { graphql, PublicApiRenderer } from '@kiwicom/mobile-relay';
-import { DateFormatter, Translation } from '@kiwicom/mobile-localization';
-import { GeneralError } from '@kiwicom/mobile-shared';
+import { graphql } from '@kiwicom/mobile-relay';
+import { DateFormatter } from '@kiwicom/mobile-localization';
 
 import { withHotelsContext } from '../HotelsContext';
 import type { Stay22HotelsSearchQueryResponse } from './__generated__/Stay22HotelsSearchQuery.graphql';
 import Stay22PaginationContainer from './Stay22PaginationContainer';
+import HotelsSearch from './HotelsSearch';
 
 type Props = {|
   +checkin: Date | null,
@@ -16,8 +16,19 @@ type Props = {|
   +getGuestCount: () => number,
   +longitude: number | null,
   +latitude: number | null,
+  +onClose: () => void,
 |};
 
+const query = graphql`
+  query Stay22HotelsSearchQuery(
+    $search: Stay22HotelsSearchInput!
+    $first: Int
+    $after: String
+  ) {
+    ...Stay22PaginationContainer
+  }
+`;
+
 export class Stay22HotelsSearch extends React.Component<Props> {
   renderAllHotelsSearchList = (
     propsFromRenderer: Stay22HotelsSearchQueryResponse,
@@ -27,39 +38,24 @@ export class Stay22HotelsSearch extends React.Component<Props> {
 
   render() {
     const { checkin, checkout, latitude, longitude, currency } = this.props;
-    if (
+    const shouldRenderDateError =
       checkin === null ||
       checkout === null ||
       latitude === null ||
-      longitude === null
-    ) {
-      return (
-        <GeneralError
-          testID="render-error"
-          errorMessage={
-            <Translation id="hotels_search.all_hotels_search.date_error" />
-          }
-        />
-      );
-    }
+      longitude === null;
+
     return (
-      <PublicApiRenderer
-        query={graphql`
-          query Stay22HotelsSearchQuery(
-            $search: Stay22HotelsSearchInput!
-            $first: Int
-            $after: String
-          ) {
-            ...Stay22PaginationContainer
-          }
-        `}
+      <HotelsSearch
+        onClose={this.props.onClose}
+        shouldRenderDateError={shouldRenderDateError}
+        query={query}
         variables={{
           first: 50,
           search: {
             latitude,
             longitude,
-            checkin: DateFormatter(checkin).formatForMachine(),
-            checkout: DateFormatter(checkout).formatForMachine(),
+            checkin: DateFormatter(checkin ?? new Date()).formatForMachine(),
+            checkout: DateFormatter(checkout ?? new Date()).formatForMachine(),
             guests: this.props.getGuestCount(),
             currency,
           },
@@ -77,4 +73,5 @@ export default withHotelsContext(state => ({
   getGuestCount: state.getGuestCount,
   latitude: state.latitude,
   longitude: state.longitude,
+  onClose: state.closeHotels,
 }))(Stay22HotelsSearch);
diff --git a/app/hotels/src/appRegistry/NewHotelsStandalonePackage.js b/app/hotels/src/appRegistry/NewHotelsStandalonePackage.js
index 6f6a2239..77f59460 100644
--- a/app/hotels/src/appRegistry/NewHotelsStandalonePackage.js
+++ b/app/hotels/src/appRegistry/NewHotelsStandalonePackage.js
@@ -22,6 +22,7 @@ type Props = {
   +checkout?: string,
   +onNavigationStateChange: () => void,
   +onBackClicked: () => void,
+  +lastNavigationMode?: string,
   +dimensions: DimensionType,
   +version: string,
   +cityName: string,
@@ -57,6 +58,8 @@ class NewHotelsStandalonePackage extends React.Component<Props> {
         // Stay 22 only supports searching with gps coordinates, if we get passed a cityId from native
         // we are using booking.com, if we don't get cityId, we should have coordinates, and we are using stay22 provider
         apiProvider={this.props.cityId == null ? 'stay22' : 'booking'}
+        onBackClicked={this.props.onBackClicked}
+        lastNavigationMode={this.props.lastNavigationMode}
       >
         <AdaptableLayout
           renderOnNarrow={<HotelsStack {...props} />}
diff --git a/app/hotels/src/appRegistry/RootComponent.js b/app/hotels/src/appRegistry/RootComponent.js
index b2557301..19676038 100644
--- a/app/hotels/src/appRegistry/RootComponent.js
+++ b/app/hotels/src/appRegistry/RootComponent.js
@@ -2,7 +2,11 @@
 
 import * as React from 'react';
 import { ConfigContext } from '@kiwicom/mobile-config';
-import { Dimensions, type DimensionType } from '@kiwicom/mobile-shared';
+import {
+  Dimensions,
+  type DimensionType,
+  GestureController,
+} from '@kiwicom/mobile-shared';
 
 import HotelsFilterContext from '../HotelsFilterContext';
 import HotelsContext, {
@@ -26,9 +30,20 @@ type Props = {|
   +coordinates?: Coordinates | null,
   +hotelId?: string,
   +apiProvider: ApiProvider,
+  +onBackClicked: () => void,
+  +lastNavigationMode?: string,
 |};
 
 export default class RootComponent extends React.Component<Props> {
+  onClosePress = () => {
+    // This prop will only come if we launch this screen from a native app
+    if (this.props.lastNavigationMode === 'present') {
+      GestureController.closeModal('NewKiwiHotels');
+    } else {
+      this.props.onBackClicked();
+    }
+  };
+
   render() {
     return (
       <SearchResultsContext.Provider>
@@ -46,6 +61,7 @@ export default class RootComponent extends React.Component<Props> {
               longitude={this.props.coordinates?.longitude ?? null}
               hotelId={this.props.hotelId}
               apiProvider={this.props.apiProvider}
+              closeHotels={this.onClosePress}
             >
               <Dimensions.Provider dimensions={this.props.dimensions}>
                 {this.props.children}
diff --git a/app/hotels/src/appRegistry/SingleHotelStandalonePackage.js b/app/hotels/src/appRegistry/SingleHotelStandalonePackage.js
index f7ca9af1..96d44744 100644
--- a/app/hotels/src/appRegistry/SingleHotelStandalonePackage.js
+++ b/app/hotels/src/appRegistry/SingleHotelStandalonePackage.js
@@ -22,6 +22,7 @@ type Props = {
   +onBackClicked: () => void,
   +dimensions: DimensionType,
   +version: string,
+  +lastNavigationMode?: string,
 };
 
 class SingleHotelStandAlonePackage extends React.Component<Props> {
@@ -45,6 +46,8 @@ class SingleHotelStandAlonePackage extends React.Component<Props> {
         roomsConfiguration={this.props.roomsConfiguration}
         hotelId={this.props.hotelId}
         apiProvider="booking" // Not sure if this is in use by native team, but for now, just support booking.com
+        lastNavigationMode={this.props.lastNavigationMode}
+        onBackClicked={this.props.onBackClicked}
       >
         <SingleHotelStack
           screenProps={screenProps}
diff --git a/app/hotels/src/components/CloseModal.js b/app/hotels/src/components/CloseModal.js
new file mode 100644
index 00000000..b284d1e9
--- /dev/null
+++ b/app/hotels/src/components/CloseModal.js
@@ -0,0 +1,26 @@
+// @flow strict
+
+import * as React from 'react';
+import { View } from 'react-native';
+import { CloseButton, StyleSheet, Device } from '@kiwicom/mobile-shared';
+
+type Props = {|
+  +onPress: () => void,
+|};
+
+export default function CloseModal(props: Props) {
+  return (
+    <View style={styles.button}>
+      <CloseButton onPress={props.onPress} />
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  button: {
+    position: 'absolute',
+    bottom: Device.isIPhoneX ? 36 : 8,
+    start: 8,
+    end: 8,
+  },
+});
diff --git a/app/hotels/src/filter/FilterStripe.js b/app/hotels/src/filter/FilterStripe.js
index b4aae57d..64f79308 100644
--- a/app/hotels/src/filter/FilterStripe.js
+++ b/app/hotels/src/filter/FilterStripe.js
@@ -34,70 +34,49 @@ type Props = {|
  * rendered first.
  * The Filters component will handle this ordering.
  */
-class FilterStripe extends React.Component<Props> {
-  scrollViewRef: React.ElementRef<typeof ScrollView>;
-
-  onChange = (params: OnChangeFilterParams) => {
-    this.props.onChange(params);
-    // setTimeout will make it run after the re-render and it scrolls to correct position
-    setTimeout(() => {
-      this.scrollViewRef.scrollTo({
-        x: 0,
-        y: 0,
-        animated: true,
-      });
-    });
-  };
-
-  storeScrollViewRef = ref => (this.scrollViewRef = ref);
-
-  render() {
-    return (
-      <View style={styles.view}>
-        <ScrollView
-          ref={this.storeScrollViewRef}
-          contentContainerStyle={[styles.scrollView, styles.backgroundNew]}
-          horizontal={true}
-          showsHorizontalScrollIndicator={false}
-        >
-          <Filters>
-            <OrderFilter
-              orderBy={this.props.orderBy}
-              isActive={this.props.activeFilters.isOrderFilterActive}
-              onChange={this.onChange}
-            />
-            <StarsFilter
-              stars={this.props.filter.starsRating}
-              onChange={this.onChange}
-              isActive={this.props.activeFilters.isStarsFilterActive}
-            />
-            <PriceFilter
-              currency={this.props.currency}
-              start={this.props.filter.minPrice}
-              end={this.props.filter.maxPrice}
-              onChange={this.onChange}
-              isActive={this.props.activeFilters.isPriceFilterActive}
-            />
-            <ScoreFilter
-              minScore={this.props.filter.minScore}
-              onChange={this.onChange}
-              isActive={this.props.activeFilters.isMinScoreActive}
-            />
-            <HotelFacilitiesFilter
-              onChange={this.onChange}
-              facilities={this.props.filter.hotelFacilities}
-              isActive={this.props.activeFilters.isHotelFacilitiesActive}
-            />
-            <FreeCancellationFilter
-              onChange={this.onChange}
-              isActive={this.props.filter.freeCancellation}
-            />
-          </Filters>
-        </ScrollView>
-      </View>
-    );
-  }
-}
+const FilterStripe = (props: Props) => (
+  <View style={styles.view}>
+    <ScrollView
+      contentContainerStyle={[styles.scrollView, styles.backgroundNew]}
+      horizontal={true}
+      showsHorizontalScrollIndicator={false}
+    >
+      <Filters>
+        <OrderFilter
+          orderBy={props.orderBy}
+          isActive={props.activeFilters.isOrderFilterActive}
+          onChange={props.onChange}
+        />
+        <StarsFilter
+          stars={props.filter.starsRating}
+          onChange={props.onChange}
+          isActive={props.activeFilters.isStarsFilterActive}
+        />
+        <PriceFilter
+          currency={props.currency}
+          start={props.filter.minPrice}
+          end={props.filter.maxPrice}
+          onChange={props.onChange}
+          isActive={props.activeFilters.isPriceFilterActive}
+        />
+        <ScoreFilter
+          minScore={props.filter.minScore}
+          onChange={props.onChange}
+          isActive={props.activeFilters.isMinScoreActive}
+        />
+        <HotelFacilitiesFilter
+          onChange={props.onChange}
+          facilities={props.filter.hotelFacilities}
+          isActive={props.activeFilters.isHotelFacilitiesActive}
+        />
+        <FreeCancellationFilter
+          onChange={props.onChange}
+          isActive={props.filter.freeCancellation}
+        />
+      </Filters>
+    </ScrollView>
+  </View>
+);
 
 const styles = StyleSheet.create({
   view: {
diff --git a/app/hotels/src/navigation/allHotels/SearchResultsScreen.js b/app/hotels/src/navigation/allHotels/SearchResultsScreen.js
index 83e0e13f..2e22131a 100644
--- a/app/hotels/src/navigation/allHotels/SearchResultsScreen.js
+++ b/app/hotels/src/navigation/allHotels/SearchResultsScreen.js
@@ -7,10 +7,7 @@ import {
   LayoutDoubleColumn,
   StyleSheet,
   AdaptableLayout,
-  GestureController,
   TextIcon,
-  CloseButton,
-  Device,
 } from '@kiwicom/mobile-shared';
 import { defaultTokens } from '@kiwicom/mobile-orbit';
 
@@ -134,24 +131,12 @@ class SearchResultsScreen extends React.Component<Props> {
 
   toggleShowMap = (show: ResultType) => this.props.setResultType(show);
 
-  onClosePress = () => {
-    // This prop will only come if we launch this screen from a native app
-    if (this.props.lastNavigationMode === 'present') {
-      GestureController.closeModal('NewKiwiHotels');
-    } else {
-      this.props.onBackClicked();
-    }
-  };
-
   render() {
     return (
       <LayoutDoubleColumn
         menuComponent={
           <View style={styles.container}>
             <NewAllHotels />
-            <View style={styles.button}>
-              <CloseButton onPress={this.onClosePress} />
-            </View>
           </View>
         }
         containerComponent={<SingleHotelContainer goBack={noop} />}
@@ -165,12 +150,6 @@ const styles = StyleSheet.create({
     flex: 1,
     backgroundColor: defaultTokens.paletteWhite,
   },
-  button: {
-    position: 'absolute',
-    bottom: Device.isIPhoneX ? 36 : 8,
-    start: 8,
-    end: 8,
-  },
   icon: {
     marginEnd: 2,
     fontSize: 22,
diff --git a/packages/relay/index.js b/packages/relay/index.js
index 955544a6..bafe1bb9 100644
--- a/packages/relay/index.js
+++ b/packages/relay/index.js
@@ -57,7 +57,9 @@ type CommitMutationConfig = {|
 export type QueryRendererProps = {|
   +query: string,
   +render: (props: Object) => React.Node,
+  +renderOfflineScreen?: ?(retry: () => void) => React.Node,
   +variables?: Object,
+  +footer?: ?React.Node,
 |};
 
 export type RelayPaginationProp = {|
diff --git a/packages/relay/src/PrivateApiRenderer.js b/packages/relay/src/PrivateApiRenderer.js
index 6a5f17bd..faffa63f 100644
--- a/packages/relay/src/PrivateApiRenderer.js
+++ b/packages/relay/src/PrivateApiRenderer.js
@@ -23,6 +23,8 @@ export function PrivateApiRenderer(props: PropsWithContext) {
       variables={props.variables}
       render={props.render}
       accessToken={props.accessToken}
+      renderOfflineScreen={props.renderOfflineScreen}
+      footer={props.footer}
     />
   );
 }
diff --git a/packages/relay/src/PublicApiRenderer.js b/packages/relay/src/PublicApiRenderer.js
index b8d5cfd8..c44acd49 100644
--- a/packages/relay/src/PublicApiRenderer.js
+++ b/packages/relay/src/PublicApiRenderer.js
@@ -11,6 +11,8 @@ export default function PublicApiRenderer(props: QueryRendererProps) {
       query={props.query}
       variables={props.variables}
       render={props.render}
+      renderOfflineScreen={props.renderOfflineScreen}
+      footer={props.footer}
     />
   );
 }
diff --git a/packages/relay/src/QueryRenderer.js b/packages/relay/src/QueryRenderer.js
index f4cd65a3..8f7a3628 100644
--- a/packages/relay/src/QueryRenderer.js
+++ b/packages/relay/src/QueryRenderer.js
@@ -18,6 +18,7 @@ type Props = {|
 type RendererResponse = {|
   +error?: Object,
   +props?: Object,
+  +retry: () => void,
 |};
 
 const noConnectionErrorMessages = [
@@ -35,22 +36,26 @@ export default class QueryRenderer extends React.Component<Props> {
     return PrivateEnvironment.getEnvironment(accessToken);
   };
 
-  renderRelayContainer = ({ error, props }: RendererResponse) => {
+  renderRelayContainer = ({ error, props, retry }: RendererResponse) => {
+    let Component = <FullPageLoading />; // no data or errors yet
     if (error && noConnectionErrorMessages.includes(error.message)) {
-      return (
+      if (this.props.renderOfflineScreen != null) {
+        return this.props.renderOfflineScreen(retry);
+      }
+      Component = (
         <GeneralError
           errorMessage={<Translation id="relay.query_renderer.no_connection" />}
         />
       );
     } else if (error instanceof TimeoutError) {
-      return (
+      Component = (
         <GeneralError
           errorMessage={<Translation id="relay.query_renderer.timeout" />}
         />
       );
     } else if (error) {
       // total failure (data == null, errors != null)
-      return (
+      Component = (
         <GeneralError
           errorMessage={<Translation passThrough={error.message} />}
         />
@@ -59,11 +64,15 @@ export default class QueryRenderer extends React.Component<Props> {
 
     if (props) {
       // success (data != null, errors == null)
-      return this.props.render(props);
+      Component = this.props.render(props);
     }
 
-    // no data or errors yet
-    return <FullPageLoading />;
+    return (
+      <React.Fragment>
+        {Component}
+        {this.props.footer}
+      </React.Fragment>
+    );
   };
 
   render() {
diff --git a/packages/shared/src/offlineScreen/OfflineScreen.js b/packages/shared/src/offlineScreen/OfflineScreen.js
index ddbd9e6f..4ce70977 100644
--- a/packages/shared/src/offlineScreen/OfflineScreen.js
+++ b/packages/shared/src/offlineScreen/OfflineScreen.js
@@ -4,6 +4,7 @@ import * as React from 'react';
 import { View } from 'react-native';
 import { Translation } from '@kiwicom/mobile-localization';
 import { defaultTokens } from '@kiwicom/mobile-orbit';
+import { SafeAreaView } from 'react-navigation';
 
 import OfflineImage from './offline.png';
 import StyleSheet from '../PlatformStyleSheet';
@@ -19,40 +20,51 @@ type Props = {|
 
 export default function OfflineScreen(props: Props) {
   return (
-    <View style={styles.container}>
-      <Text style={styles.title}>
-        <Translation id="shared.offline_screen.offline_title" />
-      </Text>
-      <Text style={styles.text}>
-        <Translation id="shared_offline_screen.offline_text" />
-      </Text>
-      <View style={styles.imageContainer}>
-        <Image source={OfflineImage} />
+    <SafeAreaView style={styles.flexItem}>
+      <View style={styles.container}>
+        <View style={styles.flexItem} />
+        <View style={styles.flexItem}>
+          <Text style={styles.title}>
+            <Translation id="shared.offline_screen.offline_title" />
+          </Text>
+          <Text style={styles.text}>
+            <Translation id="shared_offline_screen.offline_text" />
+          </Text>
+        </View>
+        <View style={styles.imageContainer}>
+          <Image source={OfflineImage} />
+        </View>
+        <View style={styles.buttonContainer}>
+          <CloseButton onPress={props.onClose} style={styles.closeButton} />
+          <TextButton
+            title={<Translation id="shared_offline_screen.try_again" />}
+            onPress={props.onTryAgain}
+          />
+        </View>
       </View>
-      <View style={styles.buttonContainer}>
-        <CloseButton onPress={props.onClose} style={styles.closeButton} />
-        <TextButton
-          title={<Translation id="shared_offline_screen.try_again" />}
-          onPress={props.onTryAgain}
-        />
-      </View>
-    </View>
+    </SafeAreaView>
   );
 }
 
 const styles = StyleSheet.create({
+  flexItem: {
+    flex: 1,
+  },
   container: {
     flex: 1,
-    justifyContent: 'flex-end',
+    justifyContent: 'flex-start',
     alignItems: 'center',
   },
   imageContainer: {
     height: 163,
     width: '100%',
+    marginBottom: 40,
+    paddingHorizontal: 22,
   },
   title: {
     fontWeight: '500',
     color: defaultTokens.colorTextAttention,
+    textAlign: 'center',
   },
   text: {
     color: defaultTokens.colorTextSecondary,


Test patch:
diff --git a/app/hotels/src/__tests__/HotelsContext.test.js b/app/hotels/src/__tests__/HotelsContext.test.js
index 5ff97ccf..659e44c3 100644
--- a/app/hotels/src/__tests__/HotelsContext.test.js
+++ b/app/hotels/src/__tests__/HotelsContext.test.js
@@ -19,6 +19,7 @@ const props = {
   cityName: null,
   apiProvider: 'booking',
   hotelId: '',
+  closeHotels: jest.fn(),
 };
 
 const Child = () => null;
diff --git a/app/hotels/src/allHotels/__tests__/Stay22HotelsSearch.test.js b/app/hotels/src/allHotels/__tests__/Stay22HotelsSearch.test.js
index 82aaae28..0bf224c1 100644
--- a/app/hotels/src/allHotels/__tests__/Stay22HotelsSearch.test.js
+++ b/app/hotels/src/allHotels/__tests__/Stay22HotelsSearch.test.js
@@ -12,6 +12,7 @@ const getProps = (props: Object = {}) => ({
   roomsConfiguration: [{ adultsCount: 2, children: [] }],
   longitude: 0,
   latitude: 0,
+  getGuestCount: jest.fn(),
   ...props,
 });
 
diff --git a/packages/relay/src/__tests__/QueryRenderer.test.js b/packages/relay/src/__tests__/QueryRenderer.test.js
index f494e752..574fe91b 100644
--- a/packages/relay/src/__tests__/QueryRenderer.test.js
+++ b/packages/relay/src/__tests__/QueryRenderer.test.js
@@ -1,5 +1,7 @@
 // @flow
 
+import * as React from 'react';
+
 import QueryRenderer from '../QueryRenderer';
 import PrivateEnvironment from '../PrivateEnvironment';
 import PublicEnvironment from '../PublicEnvironment';
@@ -11,26 +13,60 @@ afterEach(() => {
   jest.resetAllMocks();
 });
 
+const retry = () => {};
+
 describe('QueryRenderer', () => {
   it('returns general error component for network failure', () => {
-    const QR = new QueryRenderer();
+    // $FlowExpectedError: Don't need all props for this test
+    const QR = new QueryRenderer({});
     expect(
       QR.renderRelayContainer({
         error: new TypeError('Network request failed'),
+        retry,
       }),
     ).toMatchSnapshot();
   });
 
   it('returns general error for other errors', () => {
-    const QR = new QueryRenderer();
+    // $FlowExpectedError: Don't need all props for this test
+    const QR = new QueryRenderer({});
     expect(
-      QR.renderRelayContainer({ error: new Error('custom message') }),
+      QR.renderRelayContainer({ error: new Error('custom message'), retry }),
     ).toMatchSnapshot();
   });
 
   it('returns loader if no props and no errors', () => {
-    const QR = new QueryRenderer();
-    expect(QR.renderRelayContainer({})).toMatchSnapshot();
+    // $FlowExpectedError: Don't need all props for this test
+    const QR = new QueryRenderer({});
+    expect(
+      QR.renderRelayContainer({
+        retry,
+      }),
+    ).toMatchSnapshot();
+  });
+
+  it('calls renderOfflineScreen if passed', () => {
+    const renderOfflineScreen = jest.fn();
+
+    // $FlowExpectedError: Don't need all props for this test
+    const QR = new QueryRenderer({ renderOfflineScreen });
+    QR.renderRelayContainer({
+      error: new TypeError('Network request failed'),
+      retry,
+    });
+    expect(renderOfflineScreen).toHaveBeenCalledWith(retry);
+  });
+
+  it('renders footerComponent if passed', () => {
+    const Footer = () => 'test';
+
+    // $FlowExpectedError: Don't need all props for this test
+    const QR = new QueryRenderer({ footer: <Footer /> });
+    expect(
+      QR.renderRelayContainer({
+        retry,
+      }),
+    ).toMatchSnapshot();
   });
 
   it('calls PublicEnvironment.getEnvironment if no token is passed', () => {
diff --git a/packages/relay/src/__tests__/__snapshots__/QueryRenderer.test.js.snap b/packages/relay/src/__tests__/__snapshots__/QueryRenderer.test.js.snap
index c2fe7030..2dd5688a 100644
--- a/packages/relay/src/__tests__/__snapshots__/QueryRenderer.test.js.snap
+++ b/packages/relay/src/__tests__/__snapshots__/QueryRenderer.test.js.snap
@@ -1,23 +1,38 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`QueryRenderer renders footerComponent if passed 1`] = `
+<React.Fragment>
+  <FullPageLoading />
+  <Footer />
+</React.Fragment>
+`;
+
 exports[`QueryRenderer returns general error component for network failure 1`] = `
-<GeneralError
-  errorMessage={
-    <Translation
-      id="relay.query_renderer.no_connection"
-    />
-  }
-/>
+<React.Fragment>
+  <GeneralError
+    errorMessage={
+      <Translation
+        id="relay.query_renderer.no_connection"
+      />
+    }
+  />
+</React.Fragment>
 `;
 
 exports[`QueryRenderer returns general error for other errors 1`] = `
-<GeneralError
-  errorMessage={
-    <Translation
-      passThrough="custom message"
-    />
-  }
-/>
+<React.Fragment>
+  <GeneralError
+    errorMessage={
+      <Translation
+        passThrough="custom message"
+      />
+    }
+  />
+</React.Fragment>
 `;
 
-exports[`QueryRenderer returns loader if no props and no errors 1`] = `<FullPageLoading />`;
+exports[`QueryRenderer returns loader if no props and no errors 1`] = `
+<React.Fragment>
+  <FullPageLoading />
+</React.Fragment>
+`;
diff --git a/packages/shared/src/offlineScreen/__tests__/__snapshots__/OfflineScreen.test.js.snap b/packages/shared/src/offlineScreen/__tests__/__snapshots__/OfflineScreen.test.js.snap
index c1225ea9..65b0c769 100644
--- a/packages/shared/src/offlineScreen/__tests__/__snapshots__/OfflineScreen.test.js.snap
+++ b/packages/shared/src/offlineScreen/__tests__/__snapshots__/OfflineScreen.test.js.snap
@@ -1,84 +1,110 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`renders 1`] = `
-<Component
+<withOrientation
   style={
     Object {
-      "alignItems": "center",
       "flex": 1,
-      "justifyContent": "flex-end",
     }
   }
 >
-  <Text
-    style={
-      Object {
-        "color": "#171b1e",
-        "fontWeight": "500",
-      }
-    }
-  >
-    <Translation
-      id="shared.offline_screen.offline_title"
-    />
-  </Text>
-  <Text
-    style={
-      Object {
-        "color": "#7f91a8",
-        "marginHorizontal": 32,
-        "marginTop": 16,
-        "textAlign": "center",
-      }
-    }
-  >
-    <Translation
-      id="shared_offline_screen.offline_text"
-    />
-  </Text>
   <Component
     style={
       Object {
-        "height": 163,
-        "width": "100%",
+        "alignItems": "center",
+        "flex": 1,
+        "justifyContent": "flex-start",
       }
     }
   >
-    <StretchedImage
-      source={
+    <Component
+      style={
         Object {
-          "testUri": "../../../packages/shared/src/offlineScreen/offline.png",
+          "flex": 1,
         }
       }
     />
-  </Component>
-  <Component
-    style={
-      Object {
-        "flexDirection": "row",
-        "justifyContent": "space-between",
-        "padding": 8,
-      }
-    }
-  >
-    <CloseButton
-      onPress={[MockFunction]}
+    <Component
       style={
         Object {
-          "marginEnd": 8,
+          "flex": 1,
         }
       }
-    />
-    <TextButton
-      disabled={false}
-      onPress={[MockFunction]}
-      title={
+    >
+      <Text
+        style={
+          Object {
+            "color": "#171b1e",
+            "fontWeight": "500",
+            "textAlign": "center",
+          }
+        }
+      >
         <Translation
-          id="shared_offline_screen.try_again"
+          id="shared.offline_screen.offline_title"
         />
+      </Text>
+      <Text
+        style={
+          Object {
+            "color": "#7f91a8",
+            "marginHorizontal": 32,
+            "marginTop": 16,
+            "textAlign": "center",
+          }
+        }
+      >
+        <Translation
+          id="shared_offline_screen.offline_text"
+        />
+      </Text>
+    </Component>
+    <Component
+      style={
+        Object {
+          "height": 163,
+          "marginBottom": 40,
+          "paddingHorizontal": 22,
+          "width": "100%",
+        }
       }
-      type="primary"
-    />
+    >
+      <StretchedImage
+        source={
+          Object {
+            "testUri": "../../../packages/shared/src/offlineScreen/offline.png",
+          }
+        }
+      />
+    </Component>
+    <Component
+      style={
+        Object {
+          "flexDirection": "row",
+          "justifyContent": "space-between",
+          "padding": 8,
+        }
+      }
+    >
+      <CloseButton
+        onPress={[MockFunction]}
+        style={
+          Object {
+            "marginEnd": 8,
+          }
+        }
+      />
+      <TextButton
+        disabled={false}
+        onPress={[MockFunction]}
+        title={
+          <Translation
+            id="shared_offline_screen.try_again"
+          />
+        }
+        type="primary"
+      />
+    </Component>
   </Component>
-</Component>
+</withOrientation>
 `;