mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-31 02:26:50 +10:00
603 lines
20 KiB
JavaScript
603 lines
20 KiB
JavaScript
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
import React, { Component } from 'react';
|
|
import { array, arrayOf, bool, func, shape, string, oneOf } from 'prop-types';
|
|
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
|
|
import { compose } from 'redux';
|
|
import { connect } from 'react-redux';
|
|
import { withRouter } from 'react-router-dom';
|
|
import config from '../../config';
|
|
import routeConfiguration from '../../routeConfiguration';
|
|
import { LISTING_STATE_PENDING_APPROVAL, LISTING_STATE_CLOSED, propTypes } from '../../util/types';
|
|
import { types as sdkTypes } from '../../util/sdkLoader';
|
|
import { LISTING_PAGE_PENDING_APPROVAL_VARIANT, createSlug, parse } from '../../util/urlHelpers';
|
|
import { formatMoney } from '../../util/currency';
|
|
import { createResourceLocatorString, findRouteByRouteName } from '../../util/routes';
|
|
import { ensureListing, ensureOwnListing, ensureUser, userDisplayName } from '../../util/data';
|
|
import { richText } from '../../util/richText';
|
|
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
|
|
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
|
|
import {
|
|
Page,
|
|
NamedLink,
|
|
NamedRedirect,
|
|
LayoutSingleColumn,
|
|
LayoutWrapperTopbar,
|
|
LayoutWrapperMain,
|
|
LayoutWrapperFooter,
|
|
Footer,
|
|
} from '../../components';
|
|
import { TopbarContainer, NotFoundPage } from '../../containers';
|
|
|
|
import { sendEnquiry, loadData, setInitialValues } from './ListingPage.duck';
|
|
import SectionImages from './SectionImages';
|
|
import SectionAvatar from './SectionAvatar';
|
|
import SectionHeading from './SectionHeading';
|
|
import SectionDescription from './SectionDescription';
|
|
import SectionFeatures from './SectionFeatures';
|
|
import SectionReviews from './SectionReviews';
|
|
import SectionHost from './SectionHost';
|
|
import SectionRulesMaybe from './SectionRulesMaybe';
|
|
import SectionMapMaybe from './SectionMapMaybe';
|
|
import SectionBooking from './SectionBooking';
|
|
import css from './ListingPage.css';
|
|
|
|
const MIN_LENGTH_FOR_LONG_WORDS_IN_TITLE = 16;
|
|
|
|
const { UUID } = sdkTypes;
|
|
|
|
const priceData = (price, intl) => {
|
|
if (price && price.currency === config.currency) {
|
|
const formattedPrice = formatMoney(intl, price);
|
|
return { formattedPrice, priceTitle: formattedPrice };
|
|
} else if (price) {
|
|
return {
|
|
formattedPrice: `(${price.currency})`,
|
|
priceTitle: `Unsupported currency (${price.currency})`,
|
|
};
|
|
}
|
|
return {};
|
|
};
|
|
|
|
const openBookModal = (history, listing) => {
|
|
if (!listing.id) {
|
|
// Listing not fully loaded yet
|
|
return;
|
|
}
|
|
const routes = routeConfiguration();
|
|
history.push(
|
|
createResourceLocatorString(
|
|
'ListingPage',
|
|
routes,
|
|
{ id: listing.id.uuid, slug: createSlug(listing.attributes.title) },
|
|
{ book: true }
|
|
)
|
|
);
|
|
};
|
|
|
|
const closeBookModal = (history, listing) => {
|
|
if (!listing.id) {
|
|
// Listing not fully loaded yet
|
|
return;
|
|
}
|
|
const routes = routeConfiguration();
|
|
history.push(
|
|
createResourceLocatorString(
|
|
'ListingPage',
|
|
routes,
|
|
{ id: listing.id.uuid, slug: createSlug(listing.attributes.title) },
|
|
{}
|
|
)
|
|
);
|
|
};
|
|
|
|
const categoryLabel = (categories, key) => {
|
|
const cat = categories.find(c => c.key === key);
|
|
return cat ? cat.label : key;
|
|
};
|
|
|
|
export class ListingPageComponent extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
const { enquiryModalOpenForListingId, params } = props;
|
|
this.state = {
|
|
pageClassNames: [],
|
|
imageCarouselOpen: false,
|
|
enquiryModalOpen: enquiryModalOpenForListingId === params.id,
|
|
};
|
|
|
|
this.handleSubmit = this.handleSubmit.bind(this);
|
|
this.onContactUser = this.onContactUser.bind(this);
|
|
this.onSubmitEnquiry = this.onSubmitEnquiry.bind(this);
|
|
}
|
|
|
|
handleSubmit(values) {
|
|
const { history, getListing, params, useInitialValues } = this.props;
|
|
const listingId = new UUID(params.id);
|
|
const listing = getListing(listingId);
|
|
|
|
const { bookingDates, ...bookingData } = values;
|
|
|
|
const initialValues = {
|
|
listing,
|
|
bookingData,
|
|
bookingDates: {
|
|
bookingStart: bookingDates.startDate,
|
|
bookingEnd: bookingDates.endDate,
|
|
},
|
|
};
|
|
|
|
const routes = routeConfiguration();
|
|
// Customize checkout page state with current listing and selected bookingDates
|
|
const { setInitialValues } = findRouteByRouteName('CheckoutPage', routes);
|
|
useInitialValues(setInitialValues, initialValues);
|
|
|
|
// Redirect to CheckoutPage
|
|
history.push(
|
|
createResourceLocatorString(
|
|
'CheckoutPage',
|
|
routes,
|
|
{ id: listing.id.uuid, slug: createSlug(listing.attributes.title) },
|
|
{}
|
|
)
|
|
);
|
|
}
|
|
|
|
onContactUser() {
|
|
const { currentUser, history, useInitialValues, params, location } = this.props;
|
|
|
|
if (!currentUser) {
|
|
const state = { from: `${location.pathname}${location.search}${location.hash}` };
|
|
|
|
// We need to log in before showing the modal, but first we need to ensure
|
|
// that modal does open when user is redirected back to this listingpage
|
|
useInitialValues(setInitialValues, { enquiryModalOpenForListingId: params.id });
|
|
|
|
// signup and return back to listingPage.
|
|
history.push(createResourceLocatorString('SignupPage', routeConfiguration(), {}, {}), state);
|
|
} else {
|
|
this.setState({ enquiryModalOpen: true });
|
|
}
|
|
}
|
|
|
|
onSubmitEnquiry(values) {
|
|
const { history, params, onSendEnquiry } = this.props;
|
|
const routes = routeConfiguration();
|
|
const listingId = new UUID(params.id);
|
|
const { message } = values;
|
|
|
|
onSendEnquiry(listingId, message.trim())
|
|
.then(txId => {
|
|
this.setState({ enquiryModalOpen: false });
|
|
|
|
// Redirect to OrderDetailsPage
|
|
history.push(
|
|
createResourceLocatorString('OrderDetailsPage', routes, { id: txId.uuid }, {})
|
|
);
|
|
})
|
|
.catch(() => {
|
|
// Ignore, error handling in duck file
|
|
});
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
unitType,
|
|
isAuthenticated,
|
|
currentUser,
|
|
getListing,
|
|
getOwnListing,
|
|
intl,
|
|
onManageDisableScrolling,
|
|
params: rawParams,
|
|
location,
|
|
scrollingDisabled,
|
|
showListingError,
|
|
history,
|
|
reviews,
|
|
fetchReviewsError,
|
|
sendEnquiryInProgress,
|
|
sendEnquiryError,
|
|
categoriesConfig,
|
|
amenitiesConfig,
|
|
} = this.props;
|
|
|
|
const isBook = !!parse(location.search).book;
|
|
const listingId = new UUID(rawParams.id);
|
|
const isPendingApprovalVariant = rawParams.variant === LISTING_PAGE_PENDING_APPROVAL_VARIANT;
|
|
const currentListing = isPendingApprovalVariant
|
|
? ensureOwnListing(getOwnListing(listingId))
|
|
: ensureListing(getListing(listingId));
|
|
|
|
const listingSlug = rawParams.slug || createSlug(currentListing.attributes.title || '');
|
|
const params = { slug: listingSlug, ...rawParams };
|
|
|
|
const isApproved =
|
|
currentListing.id && currentListing.attributes.state !== LISTING_STATE_PENDING_APPROVAL;
|
|
|
|
const pendingIsApproved = isPendingApprovalVariant && isApproved;
|
|
|
|
// If a /pending-approval URL is shared, the UI requires
|
|
// authentication and attempts to fetch the listing from own
|
|
// listings. This will fail with 403 Forbidden if the author is
|
|
// another user. We use this information to try to fetch the
|
|
// public listing.
|
|
const pendingOtherUsersListing =
|
|
isPendingApprovalVariant && showListingError && showListingError.status === 403;
|
|
const shouldShowPublicListingPage = pendingIsApproved || pendingOtherUsersListing;
|
|
|
|
if (shouldShowPublicListingPage) {
|
|
return <NamedRedirect name="ListingPage" params={params} search={location.search} />;
|
|
}
|
|
|
|
const {
|
|
description = '',
|
|
geolocation = null,
|
|
price = null,
|
|
title = '',
|
|
publicData,
|
|
} = currentListing.attributes;
|
|
|
|
const richTitle = (
|
|
<span>
|
|
{richText(title, {
|
|
longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS_IN_TITLE,
|
|
longWordClass: css.longWord,
|
|
})}
|
|
</span>
|
|
);
|
|
|
|
const topbar = <TopbarContainer />;
|
|
|
|
if (showListingError && showListingError.status === 404) {
|
|
// 404 listing not found
|
|
|
|
return <NotFoundPage />;
|
|
} else if (showListingError) {
|
|
// Other error in fetching listing
|
|
|
|
const errorTitle = intl.formatMessage({
|
|
id: 'ListingPage.errorLoadingListingTitle',
|
|
});
|
|
|
|
return (
|
|
<Page title={errorTitle} scrollingDisabled={scrollingDisabled}>
|
|
<LayoutSingleColumn className={css.pageRoot}>
|
|
<LayoutWrapperTopbar>{topbar}</LayoutWrapperTopbar>
|
|
<LayoutWrapperMain>
|
|
<p className={css.errorText}>
|
|
<FormattedMessage id="ListingPage.errorLoadingListingMessage" />
|
|
</p>
|
|
</LayoutWrapperMain>
|
|
<LayoutWrapperFooter>
|
|
<Footer />
|
|
</LayoutWrapperFooter>
|
|
</LayoutSingleColumn>
|
|
</Page>
|
|
);
|
|
} else if (!currentListing.id) {
|
|
// Still loading the listing
|
|
|
|
const loadingTitle = intl.formatMessage({
|
|
id: 'ListingPage.loadingListingTitle',
|
|
});
|
|
|
|
return (
|
|
<Page title={loadingTitle} scrollingDisabled={scrollingDisabled}>
|
|
<LayoutSingleColumn className={css.pageRoot}>
|
|
<LayoutWrapperTopbar>{topbar}</LayoutWrapperTopbar>
|
|
<LayoutWrapperMain>
|
|
<p className={css.loadingText}>
|
|
<FormattedMessage id="ListingPage.loadingListingMessage" />
|
|
</p>
|
|
</LayoutWrapperMain>
|
|
<LayoutWrapperFooter>
|
|
<Footer />
|
|
</LayoutWrapperFooter>
|
|
</LayoutSingleColumn>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
const handleViewPhotosClick = e => {
|
|
// Stop event from bubbling up to prevent image click handler
|
|
// trying to open the carousel as well.
|
|
e.stopPropagation();
|
|
this.setState({
|
|
imageCarouselOpen: true,
|
|
});
|
|
};
|
|
const authorAvailable = currentListing && currentListing.author;
|
|
const userAndListingAuthorAvailable = !!(currentUser && authorAvailable);
|
|
const isOwnListing =
|
|
userAndListingAuthorAvailable && currentListing.author.id.uuid === currentUser.id.uuid;
|
|
const isClosed = currentListing.attributes.state === LISTING_STATE_CLOSED;
|
|
const showContactUser = !currentUser || (currentUser && !isOwnListing);
|
|
|
|
const currentAuthor = authorAvailable ? currentListing.author : null;
|
|
const ensuredAuthor = ensureUser(currentAuthor);
|
|
|
|
const bannedUserDisplayName = intl.formatMessage({
|
|
id: 'ListingPage.bannedUserDisplayName',
|
|
});
|
|
const authorDisplayName = userDisplayName(ensuredAuthor, bannedUserDisplayName);
|
|
|
|
const { formattedPrice, priceTitle } = priceData(price, intl);
|
|
|
|
const handleMobileBookModalClose = () => {
|
|
closeBookModal(history, currentListing);
|
|
};
|
|
|
|
const handleBookingSubmit = values => {
|
|
const isCurrentlyClosed = currentListing.attributes.state === LISTING_STATE_CLOSED;
|
|
if (isOwnListing || isCurrentlyClosed) {
|
|
window.scrollTo(0, 0);
|
|
} else {
|
|
this.handleSubmit(values);
|
|
}
|
|
};
|
|
|
|
const handleBookButtonClick = () => {
|
|
const isCurrentlyClosed = currentListing.attributes.state === LISTING_STATE_CLOSED;
|
|
if (isOwnListing || isCurrentlyClosed) {
|
|
window.scrollTo(0, 0);
|
|
} else {
|
|
openBookModal(history, currentListing);
|
|
}
|
|
};
|
|
|
|
const listingImages = (listing, variantName) =>
|
|
(listing.images || [])
|
|
.map(image => {
|
|
const variants = image.attributes.variants;
|
|
const variant = variants ? variants[variantName] : null;
|
|
|
|
// deprecated
|
|
// for backwards combatility only
|
|
const sizes = image.attributes.sizes;
|
|
const size = sizes ? sizes.find(i => i.name === variantName) : null;
|
|
|
|
return variant || size;
|
|
})
|
|
.filter(variant => variant != null);
|
|
|
|
const facebookImages = listingImages(currentListing, 'facebook');
|
|
const twitterImages = listingImages(currentListing, 'twitter');
|
|
const schemaImages = JSON.stringify(facebookImages.map(img => img.url));
|
|
const siteTitle = config.siteTitle;
|
|
const schemaTitle = intl.formatMessage(
|
|
{ id: 'ListingPage.schemaTitle' },
|
|
{ title, price: formattedPrice, siteTitle }
|
|
);
|
|
|
|
const hostLink = (
|
|
<NamedLink
|
|
className={css.authorNameLink}
|
|
name="ListingPage"
|
|
params={params}
|
|
to={{ hash: '#host' }}
|
|
>
|
|
{authorDisplayName}
|
|
</NamedLink>
|
|
);
|
|
|
|
const category =
|
|
publicData && publicData.category ? (
|
|
<span>
|
|
{categoryLabel(categoriesConfig, publicData.category)}
|
|
<span className={css.separator}>•</span>
|
|
</span>
|
|
) : null;
|
|
|
|
return (
|
|
<Page
|
|
title={schemaTitle}
|
|
scrollingDisabled={scrollingDisabled}
|
|
author={authorDisplayName}
|
|
contentType="website"
|
|
description={description}
|
|
facebookImages={facebookImages}
|
|
twitterImages={twitterImages}
|
|
schema={{
|
|
'@context': 'http://schema.org',
|
|
'@type': 'ItemPage',
|
|
description: description,
|
|
name: schemaTitle,
|
|
image: schemaImages,
|
|
}}
|
|
>
|
|
<LayoutSingleColumn className={css.pageRoot}>
|
|
<LayoutWrapperTopbar>{topbar}</LayoutWrapperTopbar>
|
|
<LayoutWrapperMain>
|
|
<div>
|
|
<SectionImages
|
|
title={title}
|
|
listing={currentListing}
|
|
isOwnListing={isOwnListing}
|
|
editParams={{
|
|
id: listingId.uuid,
|
|
slug: listingSlug,
|
|
type: 'edit',
|
|
tab: 'description',
|
|
}}
|
|
imageCarouselOpen={this.state.imageCarouselOpen}
|
|
onImageCarouselClose={() => this.setState({ imageCarouselOpen: false })}
|
|
handleViewPhotosClick={handleViewPhotosClick}
|
|
onManageDisableScrolling={onManageDisableScrolling}
|
|
/>
|
|
<div className={css.contentContainer}>
|
|
<SectionAvatar user={currentAuthor} params={params} />
|
|
<div className={css.mainContent}>
|
|
<SectionHeading
|
|
priceTitle={priceTitle}
|
|
formattedPrice={formattedPrice}
|
|
richTitle={richTitle}
|
|
category={category}
|
|
hostLink={hostLink}
|
|
showContactUser={showContactUser}
|
|
onContactUser={this.onContactUser}
|
|
/>
|
|
<SectionDescription description={description} />
|
|
<SectionFeatures
|
|
options={amenitiesConfig}
|
|
selectedOptions={publicData.amenities}
|
|
/>
|
|
<SectionRulesMaybe publicData={publicData} />
|
|
<SectionMapMaybe
|
|
geolocation={geolocation}
|
|
publicData={publicData}
|
|
listingId={currentListing.id}
|
|
/>
|
|
<SectionReviews reviews={reviews} fetchReviewsError={fetchReviewsError} />
|
|
<SectionHost
|
|
title={title}
|
|
listing={currentListing}
|
|
isOwnListing={isOwnListing}
|
|
authorDisplayName={authorDisplayName}
|
|
onContactUser={this.onContactUser}
|
|
isEnquiryModalOpen={isAuthenticated && this.state.enquiryModalOpen}
|
|
onCloseEnquiryModal={() => this.setState({ enquiryModalOpen: false })}
|
|
sendEnquiryError={sendEnquiryError}
|
|
sendEnquiryInProgress={sendEnquiryInProgress}
|
|
onSubmitEnquiry={this.onSubmitEnquiry}
|
|
currentUser={currentUser}
|
|
onManageDisableScrolling={onManageDisableScrolling}
|
|
/>
|
|
</div>
|
|
<SectionBooking
|
|
listing={currentListing}
|
|
isOwnListing={isOwnListing}
|
|
isClosed={isClosed}
|
|
isBook={isBook}
|
|
unitType={unitType}
|
|
price={price}
|
|
formattedPrice={formattedPrice}
|
|
priceTitle={priceTitle}
|
|
handleBookingSubmit={handleBookingSubmit}
|
|
richTitle={richTitle}
|
|
authorDisplayName={authorDisplayName}
|
|
handleBookButtonClick={handleBookButtonClick}
|
|
handleMobileBookModalClose={handleMobileBookModalClose}
|
|
onManageDisableScrolling={onManageDisableScrolling}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</LayoutWrapperMain>
|
|
<LayoutWrapperFooter>
|
|
<Footer />
|
|
</LayoutWrapperFooter>
|
|
</LayoutSingleColumn>
|
|
</Page>
|
|
);
|
|
}
|
|
}
|
|
|
|
ListingPageComponent.defaultProps = {
|
|
unitType: config.bookingUnitType,
|
|
currentUser: null,
|
|
enquiryModalOpenForListingId: null,
|
|
showListingError: null,
|
|
reviews: [],
|
|
fetchReviewsError: null,
|
|
sendEnquiryError: null,
|
|
categoriesConfig: config.custom.categories,
|
|
amenitiesConfig: config.custom.amenities,
|
|
};
|
|
|
|
ListingPageComponent.propTypes = {
|
|
// from withRouter
|
|
history: shape({
|
|
push: func.isRequired,
|
|
}).isRequired,
|
|
location: shape({
|
|
search: string,
|
|
}).isRequired,
|
|
|
|
unitType: propTypes.bookingUnitType,
|
|
// from injectIntl
|
|
intl: intlShape.isRequired,
|
|
|
|
params: shape({
|
|
id: string.isRequired,
|
|
slug: string,
|
|
variant: oneOf([LISTING_PAGE_PENDING_APPROVAL_VARIANT]),
|
|
}).isRequired,
|
|
|
|
isAuthenticated: bool.isRequired,
|
|
currentUser: propTypes.currentUser,
|
|
getListing: func.isRequired,
|
|
getOwnListing: func.isRequired,
|
|
onManageDisableScrolling: func.isRequired,
|
|
scrollingDisabled: bool.isRequired,
|
|
enquiryModalOpenForListingId: string,
|
|
showListingError: propTypes.error,
|
|
useInitialValues: func.isRequired,
|
|
reviews: arrayOf(propTypes.review),
|
|
fetchReviewsError: propTypes.error,
|
|
sendEnquiryInProgress: bool.isRequired,
|
|
sendEnquiryError: propTypes.error,
|
|
onSendEnquiry: func.isRequired,
|
|
|
|
categoriesConfig: array,
|
|
amenitiesConfig: array,
|
|
};
|
|
|
|
const mapStateToProps = state => {
|
|
const { isAuthenticated } = state.Auth;
|
|
const {
|
|
showListingError,
|
|
reviews,
|
|
fetchReviewsError,
|
|
sendEnquiryInProgress,
|
|
sendEnquiryError,
|
|
enquiryModalOpenForListingId,
|
|
} = state.ListingPage;
|
|
const { currentUser } = state.user;
|
|
|
|
const getListing = id => {
|
|
const ref = { id, type: 'listing' };
|
|
const listings = getMarketplaceEntities(state, [ref]);
|
|
return listings.length === 1 ? listings[0] : null;
|
|
};
|
|
|
|
const getOwnListing = id => {
|
|
const ref = { id, type: 'ownListing' };
|
|
const listings = getMarketplaceEntities(state, [ref]);
|
|
return listings.length === 1 ? listings[0] : null;
|
|
};
|
|
|
|
return {
|
|
isAuthenticated,
|
|
currentUser,
|
|
getListing,
|
|
getOwnListing,
|
|
scrollingDisabled: isScrollingDisabled(state),
|
|
enquiryModalOpenForListingId,
|
|
showListingError,
|
|
reviews,
|
|
fetchReviewsError,
|
|
sendEnquiryInProgress,
|
|
sendEnquiryError,
|
|
};
|
|
};
|
|
|
|
const mapDispatchToProps = dispatch => ({
|
|
onManageDisableScrolling: (componentId, disableScrolling) =>
|
|
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
|
useInitialValues: (setInitialValues, values) => dispatch(setInitialValues(values)),
|
|
onSendEnquiry: (listingId, message) => dispatch(sendEnquiry(listingId, message)),
|
|
});
|
|
|
|
// Note: it is important that the withRouter HOC is **outside** the
|
|
// connect HOC, otherwise React Router won't rerender any Route
|
|
// components since connect implements a shouldComponentUpdate
|
|
// lifecycle hook.
|
|
//
|
|
// See: https://github.com/ReactTraining/react-router/issues/4671
|
|
const ListingPage = compose(withRouter, connect(mapStateToProps, mapDispatchToProps), injectIntl)(
|
|
ListingPageComponent
|
|
);
|
|
|
|
ListingPage.setInitialValues = initialValues => setInitialValues(initialValues);
|
|
ListingPage.loadData = loadData;
|
|
|
|
export default ListingPage;
|