Merge pull request #800 from sharetribe/refactor-listing-page-contents

Split ListingPage into smaller sections
This commit is contained in:
Kimmo Puputti 2018-04-10 16:43:36 +03:00 committed by GitHub
commit 7d0e64c078
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 789 additions and 772 deletions

View file

@ -0,0 +1,61 @@
import React from 'react';
import { bool, oneOfType, object } from 'prop-types';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { LISTING_STATE_PENDING_APPROVAL, LISTING_STATE_CLOSED, propTypes } from '../../util/types';
import { NamedLink } from '../../components';
import EditIcon from './EditIcon';
import css from './ListingPage.css';
export const ActionBarMaybe = props => {
const { isOwnListing, listing, editParams } = props;
const state = listing.attributes.state;
const isPendingApproval = state === LISTING_STATE_PENDING_APPROVAL;
const isClosed = state === LISTING_STATE_CLOSED;
if (isOwnListing) {
let ownListingTextTranslationId = 'ListingPage.ownListing';
if (isPendingApproval) {
ownListingTextTranslationId = 'ListingPage.ownListingPendingApproval';
} else if (isClosed) {
ownListingTextTranslationId = 'ListingPage.ownClosedListing';
}
const ownListingTextClasses = classNames(css.ownListingText, {
[css.ownListingTextPendingApproval]: isPendingApproval,
});
return (
<div className={css.actionBar}>
<p className={ownListingTextClasses}>
<FormattedMessage id={ownListingTextTranslationId} />
</p>
<NamedLink className={css.editListingLink} name="EditListingPage" params={editParams}>
<EditIcon className={css.editIcon} />
<FormattedMessage id="ListingPage.editListing" />
</NamedLink>
</div>
);
} else if (isClosed) {
return (
<div className={css.actionBar}>
<p className={css.closedListingText}>
<FormattedMessage id="ListingPage.closedListing" />
</p>
</div>
);
}
return null;
};
ActionBarMaybe.propTypes = {
isOwnListing: bool.isRequired,
listing: oneOfType([propTypes.listing, propTypes.ownListing]).isRequired,
editParams: object.isRequired,
};
ActionBarMaybe.displayName = 'ActionBarMaybe';
export default ActionBarMaybe;

View file

@ -17,6 +17,9 @@
margin: 24px;
}
.sectionImages {
}
.threeToTwoWrapper {
/* Layout */
display: block;
@ -216,7 +219,7 @@
}
}
.avatarWrapper {
.sectionAvatar {
/* Position (over the listing image)*/
margin-left: 24px;
margin-top: -31px;
@ -252,7 +255,7 @@
}
}
.headingContainer {
.sectionHeading {
margin-top: 22px;
margin-bottom: 34px;
@ -356,7 +359,7 @@
margin: 0;
}
.descriptionContainer {
.sectionDescription {
padding: 0 24px;
margin-bottom: 35px;
@ -379,7 +382,7 @@
}
}
.featuresContainer {
.sectionFeatures {
padding: 0 24px;
margin-bottom: 32px;
@ -415,7 +418,7 @@
}
}
.locationContainer {
.sectionMap {
padding: 0 24px;
margin-bottom: 35px;
@ -438,7 +441,7 @@
}
}
.reviewsContainer {
.sectionReviews {
padding: 0 24px;
margin-bottom: 5px;
@ -464,7 +467,7 @@
}
}
.yourHostContainer {
.sectionHost {
position: relative;
padding: 0 24px;
margin-bottom: 5px;

View file

@ -1,11 +1,10 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import React, { Component } from 'react';
import { arrayOf, bool, func, object, shape, string, oneOf, oneOfType } from 'prop-types';
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 classNames from 'classnames';
import config from '../../config';
import routeConfiguration from '../../routeConfiguration';
import { LISTING_STATE_PENDING_APPROVAL, LISTING_STATE_CLOSED, propTypes } from '../../util/types';
@ -18,38 +17,31 @@ import { richText } from '../../util/richText';
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
import {
AvatarLarge,
AvatarMedium,
Button,
ModalInMobile,
Page,
ResponsiveImage,
NamedLink,
NamedRedirect,
Modal,
ImageCarousel,
InlineTextButton,
LayoutSingleColumn,
LayoutWrapperTopbar,
LayoutWrapperMain,
LayoutWrapperFooter,
Footer,
UserCard,
Reviews,
PropertyGroup,
} from '../../components';
import { BookingDatesForm, TopbarContainer, EnquiryForm, NotFoundPage } from '../../containers';
import { TopbarContainer, NotFoundPage } from '../../containers';
import { sendEnquiry, loadData, setInitialValues } from './ListingPage.duck';
import EditIcon from './EditIcon';
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';
// This defines when ModalInMobile shows content as Modal
const MODAL_BREAKPOINT = 1023;
const MIN_LENGTH_FOR_LONG_WORDS_IN_TITLE = 16;
const MIN_LENGTH_FOR_LONG_WORDS_IN_DESCRIPTION = 20;
const { UUID } = sdkTypes;
@ -66,56 +58,6 @@ const priceData = (price, intl) => {
return {};
};
export const ActionBarMaybe = props => {
const { isOwnListing, listing, editParams } = props;
const state = listing.attributes.state;
const isPendingApproval = state === LISTING_STATE_PENDING_APPROVAL;
const isClosed = state === LISTING_STATE_CLOSED;
if (isOwnListing) {
let ownListingTextTranslationId = 'ListingPage.ownListing';
if (isPendingApproval) {
ownListingTextTranslationId = 'ListingPage.ownListingPendingApproval';
} else if (isClosed) {
ownListingTextTranslationId = 'ListingPage.ownClosedListing';
}
const ownListingTextClasses = classNames(css.ownListingText, {
[css.ownListingTextPendingApproval]: isPendingApproval,
});
return (
<div className={css.actionBar}>
<p className={ownListingTextClasses}>
<FormattedMessage id={ownListingTextTranslationId} />
</p>
<NamedLink className={css.editListingLink} name="EditListingPage" params={editParams}>
<EditIcon className={css.editIcon} />
<FormattedMessage id="ListingPage.editListing" />
</NamedLink>
</div>
);
} else if (isClosed) {
return (
<div className={css.actionBar}>
<p className={css.closedListingText}>
<FormattedMessage id="ListingPage.closedListing" />
</p>
</div>
);
}
return null;
};
ActionBarMaybe.propTypes = {
isOwnListing: bool.isRequired,
listing: oneOfType([propTypes.listing, propTypes.ownListing]).isRequired,
editParams: object.isRequired,
};
ActionBarMaybe.displayName = 'ActionBarMaybe';
const openBookModal = (history, listing) => {
if (!listing.id) {
// Listing not fully loaded yet
@ -153,8 +95,6 @@ const categoryLabel = (categories, key) => {
return cat ? cat.label : key;
};
// TODO: price unit (per x), custom fields, contact, reviews
// N.B. All the presentational content needs to be extracted to their own components
export class ListingPageComponent extends Component {
constructor(props) {
super(props);
@ -257,6 +197,8 @@ export class ListingPageComponent extends Component {
fetchReviewsError,
sendEnquiryInProgress,
sendEnquiryError,
categoriesConfig,
amenitiesConfig,
} = this.props;
const isBook = !!parse(location.search).book;
@ -304,23 +246,8 @@ export class ListingPageComponent extends Component {
</span>
);
const category =
publicData && publicData.category ? (
<span>
{categoryLabel(config.custom.categories, publicData.category)}
<span className={css.separator}></span>
</span>
) : null;
const topbar = <TopbarContainer />;
const loadingTitle = intl.formatMessage({
id: 'ListingPage.loadingListingTitle',
});
const errorTitle = intl.formatMessage({
id: 'ListingPage.errorLoadingListingTitle',
});
if (showListingError && showListingError.status === 404) {
// 404 listing not found
@ -328,6 +255,10 @@ export class ListingPageComponent extends Component {
} 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}>
@ -346,6 +277,10 @@ export class ListingPageComponent extends Component {
} 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}>
@ -363,9 +298,6 @@ export class ListingPageComponent extends Component {
);
}
const hasImages = currentListing.images && currentListing.images.length > 0;
const firstImage = hasImages ? currentListing.images[0] : null;
const handleViewPhotosClick = e => {
// Stop event from bubbling up to prevent image click handler
// trying to open the carousel as well.
@ -374,15 +306,6 @@ export class ListingPageComponent extends Component {
imageCarouselOpen: true,
});
};
const viewPhotosButton = hasImages ? (
<button className={css.viewPhotos} onClick={handleViewPhotosClick}>
<FormattedMessage
id="ListingPage.viewImagesButton"
values={{ count: currentListing.images.length }}
/>
</button>
) : null;
const authorAvailable = currentListing && currentListing.author;
const userAndListingAuthorAvailable = !!(currentUser && authorAvailable);
const isOwnListing =
@ -398,27 +321,8 @@ export class ListingPageComponent extends Component {
});
const authorDisplayName = userDisplayName(ensuredAuthor, bannedUserDisplayName);
const bookBtnMessage = intl.formatMessage({ id: 'ListingPage.ctaButtonMessage' });
const { formattedPrice, priceTitle } = priceData(price, intl);
const showClosedListingHelpText = currentListing.id && isClosed;
const bookingHeading = (
<div className={css.bookingHeading}>
<h2 className={css.bookingTitle}>
<FormattedMessage id="ListingPage.bookingTitle" values={{ title: richTitle }} />
</h2>
<div className={css.bookingHelp}>
<FormattedMessage
id={
showClosedListingHelpText
? 'ListingPage.bookingHelpClosedListing'
: 'ListingPage.bookingHelp'
}
/>
</div>
</div>
);
const handleMobileBookModalClose = () => {
closeBookModal(history, currentListing);
};
@ -432,8 +336,6 @@ export class ListingPageComponent extends Component {
}
};
const editParams = { id: listingId.uuid, slug: listingSlug, type: 'edit', tab: 'description' };
const handleBookButtonClick = () => {
const isCurrentlyClosed = currentListing.attributes.state === LISTING_STATE_CLOSED;
if (isOwnListing || isCurrentlyClosed) {
@ -443,18 +345,6 @@ export class ListingPageComponent extends Component {
}
};
// Action bar is wrapped with a div that prevents the click events
// to the parent that would otherwise open the image carousel
const actionBar = currentListing.id ? (
<div onClick={e => e.stopPropagation()}>
<ActionBarMaybe
isOwnListing={isOwnListing}
listing={currentListing}
editParams={editParams}
/>
</div>
) : null;
const listingImages = (listing, variantName) =>
(listing.images || [])
.map(image => {
@ -490,11 +380,13 @@ export class ListingPageComponent extends Component {
</NamedLink>
);
const reviewsError = (
<h2 className={css.errorText}>
<FormattedMessage id="ListingPage.reviewsError" />
</h2>
);
const category =
publicData && publicData.category ? (
<span>
{categoryLabel(categoriesConfig, publicData.category)}
<span className={css.separator}></span>
</span>
) : null;
return (
<Page
@ -517,212 +409,76 @@ export class ListingPageComponent extends Component {
<LayoutWrapperTopbar>{topbar}</LayoutWrapperTopbar>
<LayoutWrapperMain>
<div>
<div className={css.threeToTwoWrapper}>
<div className={css.aspectWrapper} onClick={handleViewPhotosClick}>
{actionBar}
<ResponsiveImage
rootClassName={css.rootForImage}
alt={title}
image={firstImage}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
{ name: 'landscape-crop4x', size: '1600w' },
{ name: 'landscape-crop6x', size: '2400w' },
]}
sizes="100vw"
/>
{viewPhotosButton}
</div>
</div>
<Modal
id="ListingPage.imageCarousel"
scrollLayerClassName={css.carouselModalScrollLayer}
containerClassName={css.carouselModalContainer}
lightCloseButton
isOpen={this.state.imageCarouselOpen}
onClose={() => this.setState({ imageCarouselOpen: false })}
<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}
>
<ImageCarousel images={currentListing.images} />
</Modal>
/>
<div className={css.contentContainer}>
<div className={css.avatarWrapper}>
<NamedLink name="ListingPage" params={params} to={{ hash: '#host' }}>
<AvatarLarge
user={currentAuthor}
className={css.avatarDesktop}
disableProfileLink
/>
</NamedLink>
<NamedLink name="ListingPage" params={params} to={{ hash: '#host' }}>
<AvatarMedium
user={currentAuthor}
className={css.avatarMobile}
disableProfileLink
/>
</NamedLink>
</div>
<SectionAvatar user={currentAuthor} params={params} />
<div className={css.mainContent}>
<div className={css.headingContainer}>
<div className={css.desktopPriceContainer}>
<div className={css.desktopPriceValue} title={priceTitle}>
{formattedPrice}
</div>
<div className={css.desktopPerUnit}>
<FormattedMessage id="ListingPage.perUnit" />
</div>
</div>
<div className={css.heading}>
<h1 className={css.title}>{richTitle}</h1>
<div className={css.author}>
{category}
<FormattedMessage id="ListingPage.hostedBy" values={{ name: hostLink }} />
{showContactUser ? (
<span className={css.contactWrapper}>
<span className={css.separator}></span>
<InlineTextButton
className={css.contactLink}
onClick={this.onContactUser}
>
<FormattedMessage id="ListingPage.contactUser" />
</InlineTextButton>
</span>
) : null}
</div>
</div>
</div>
<div className={css.descriptionContainer}>
<h2 className={css.descriptionTitle}>
<FormattedMessage id="ListingPage.descriptionTitle" />
</h2>
<p className={css.description}>
{richText(description, {
longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS_IN_DESCRIPTION,
longWordClass: css.longWord,
})}
</p>
</div>
<div className={css.featuresContainer}>
<h2 className={css.featuresTitle}>
<FormattedMessage id="ListingPage.featuresTitle" />
</h2>
<PropertyGroup
id="ListingPage.amenities"
options={config.custom.amenities}
selectedOptions={publicData.amenities}
twoColumns={true}
/>
</div>
<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}
/>
<div className={css.reviewsContainer}>
<h2 className={css.reviewsHeading}>
<FormattedMessage
id="ListingPage.reviewsHeading"
values={{ count: reviews.length }}
/>
</h2>
{fetchReviewsError ? reviewsError : null}
<Reviews reviews={reviews} />
</div>
<div id="host" className={css.yourHostContainer}>
<h2 className={css.yourHostHeading}>
<FormattedMessage id="ListingPage.yourHostHeading" />
</h2>
{isOwnListing ? (
<NamedLink className={css.editProfileLink} name="ProfileSettingsPage">
<FormattedMessage id="ListingPage.editProfileLink" />
</NamedLink>
) : null}
<UserCard
user={currentListing.author}
currentUser={currentUser}
onContactUser={this.onContactUser}
/>
<Modal
id="ListingPage.enquiry"
contentClassName={css.enquiryModalContent}
isOpen={isAuthenticated && this.state.enquiryModalOpen}
onClose={() => this.setState({ enquiryModalOpen: false })}
onManageDisableScrolling={onManageDisableScrolling}
>
<EnquiryForm
className={css.enquiryForm}
submitButtonWrapperClassName={css.enquirySubmitButtonWrapper}
listingTitle={title}
authorDisplayName={authorDisplayName}
sendEnquiryError={sendEnquiryError}
onSubmit={this.onSubmitEnquiry}
inProgress={sendEnquiryInProgress}
/>
</Modal>
</div>
<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>
<ModalInMobile
className={css.modalInMobile}
containerClassName={css.modalContainer}
id="BookingDatesFormInModal"
isModalOpenOnMobile={isBook}
onClose={handleMobileBookModalClose}
showAsModalMaxWidth={MODAL_BREAKPOINT}
<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 className={css.modalHeading}>
<h1 className={css.title}>{richTitle}</h1>
<div className={css.author}>
<span className={css.authorName}>
<FormattedMessage
id="ListingPage.hostedBy"
values={{ name: authorDisplayName }}
/>
</span>
</div>
</div>
{bookingHeading}
{!isClosed ? (
<BookingDatesForm
className={css.bookingForm}
submitButtonWrapperClassName={css.bookingDatesSubmitButtonWrapper}
unitType={unitType}
onSubmit={handleBookingSubmit}
price={price}
isOwnListing={isOwnListing}
/>
) : null}
</ModalInMobile>
<div className={css.openBookingForm}>
<div className={css.priceContainer}>
<div className={css.priceValue} title={priceTitle}>
{formattedPrice}
</div>
<div className={css.perUnit}>
<FormattedMessage id="ListingPage.perUnit" />
</div>
</div>
{!isClosed ? (
<Button rootClassName={css.bookButton} onClick={handleBookButtonClick}>
{bookBtnMessage}
</Button>
) : (
<div className={css.closedListingButton}>
<FormattedMessage id="ListingPage.closedListingButtonText" />
</div>
)}
</div>
/>
</div>
</div>
</LayoutWrapperMain>
@ -743,6 +499,8 @@ ListingPageComponent.defaultProps = {
reviews: [],
fetchReviewsError: null,
sendEnquiryError: null,
categoriesConfig: config.custom.categories,
amenitiesConfig: config.custom.amenities,
};
ListingPageComponent.propTypes = {
@ -778,6 +536,9 @@ ListingPageComponent.propTypes = {
sendEnquiryInProgress: bool.isRequired,
sendEnquiryError: propTypes.error,
onSendEnquiry: func.isRequired,
categoriesConfig: array,
amenitiesConfig: array,
};
const mapStateToProps = state => {

View file

@ -24,11 +24,20 @@ import { showListingRequest, showListingError, showListing } from './ListingPage
// Otherwise, ListingPage itself is not initialized correctly when routeConfiguration is imported
// (loadData call fails).
import routeConfiguration from '../../routeConfiguration';
import { ListingPageComponent, ActionBarMaybe } from './ListingPage';
import { ListingPageComponent } from './ListingPage';
import ActionBarMaybe from './ActionBarMaybe';
const { UUID } = sdkTypes;
const noop = () => null;
const categoriesConfig = [{ key: 'cat1', label: 'Cat 1' }, { key: 'cat2', label: 'Cat 2' }];
const amenitiesConfig = [
{ key: 'feat1', label: 'Feat 1' },
{ key: 'feat2', label: 'Feat 2' },
{ key: 'feat3', label: 'Feat 3' },
];
describe('ListingPage', () => {
it('matches snapshot', () => {
const currentUser = createCurrentUser('user-2');
@ -66,6 +75,8 @@ describe('ListingPage', () => {
onResendVerificationEmail: noop,
sendEnquiryInProgress: false,
onSendEnquiry: noop,
categoriesConfig,
amenitiesConfig,
};
const tree = renderShallow(<ListingPageComponent {...props} />);

View file

@ -0,0 +1,20 @@
import React from 'react';
import { NamedLink, AvatarLarge, AvatarMedium } from '../../components';
import css from './ListingPage.css';
const SectionAvatar = props => {
const { user, params } = props;
return (
<div className={css.sectionAvatar}>
<NamedLink name="ListingPage" params={params} to={{ hash: '#host' }}>
<AvatarLarge user={user} className={css.avatarDesktop} disableProfileLink />
</NamedLink>
<NamedLink name="ListingPage" params={params} to={{ hash: '#host' }}>
<AvatarMedium user={user} className={css.avatarMobile} disableProfileLink />
</NamedLink>
</div>
);
};
export default SectionAvatar;

View file

@ -0,0 +1,98 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { ModalInMobile, Button } from '../../components';
import { BookingDatesForm } from '../../containers';
import css from './ListingPage.css';
// This defines when ModalInMobile shows content as Modal
const MODAL_BREAKPOINT = 1023;
const SectionBooking = props => {
const {
listing,
isOwnListing,
isClosed,
isBook,
unitType,
price,
formattedPrice,
priceTitle,
handleBookingSubmit,
richTitle,
authorDisplayName,
handleBookButtonClick,
handleMobileBookModalClose,
onManageDisableScrolling,
} = props;
const showClosedListingHelpText = listing.id && isClosed;
return (
<div>
<ModalInMobile
className={css.modalInMobile}
containerClassName={css.modalContainer}
id="BookingDatesFormInModal"
isModalOpenOnMobile={isBook}
onClose={handleMobileBookModalClose}
showAsModalMaxWidth={MODAL_BREAKPOINT}
onManageDisableScrolling={onManageDisableScrolling}
>
<div className={css.modalHeading}>
<h1 className={css.title}>{richTitle}</h1>
<div className={css.author}>
<span className={css.authorName}>
<FormattedMessage id="ListingPage.hostedBy" values={{ name: authorDisplayName }} />
</span>
</div>
</div>
<div className={css.bookingHeading}>
<h2 className={css.bookingTitle}>
<FormattedMessage id="ListingPage.bookingTitle" values={{ title: richTitle }} />
</h2>
<div className={css.bookingHelp}>
<FormattedMessage
id={
showClosedListingHelpText
? 'ListingPage.bookingHelpClosedListing'
: 'ListingPage.bookingHelp'
}
/>
</div>
</div>
{!isClosed ? (
<BookingDatesForm
className={css.bookingForm}
submitButtonWrapperClassName={css.bookingDatesSubmitButtonWrapper}
unitType={unitType}
onSubmit={handleBookingSubmit}
price={price}
isOwnListing={isOwnListing}
/>
) : null}
</ModalInMobile>
<div className={css.openBookingForm}>
<div className={css.priceContainer}>
<div className={css.priceValue} title={priceTitle}>
{formattedPrice}
</div>
<div className={css.perUnit}>
<FormattedMessage id="ListingPage.perUnit" />
</div>
</div>
{!isClosed ? (
<Button rootClassName={css.bookButton} onClick={handleBookButtonClick}>
<FormattedMessage id="ListingPage.ctaButtonMessage" />
</Button>
) : (
<div className={css.closedListingButton}>
<FormattedMessage id="ListingPage.closedListingButtonText" />
</div>
)}
</div>
</div>
);
};
export default SectionBooking;

View file

@ -0,0 +1,26 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { richText } from '../../util/richText';
import css from './ListingPage.css';
const MIN_LENGTH_FOR_LONG_WORDS_IN_DESCRIPTION = 20;
const SectionDescription = props => {
const { description } = props;
return (
<div className={css.sectionDescription}>
<h2 className={css.descriptionTitle}>
<FormattedMessage id="ListingPage.descriptionTitle" />
</h2>
<p className={css.description}>
{richText(description, {
longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS_IN_DESCRIPTION,
longWordClass: css.longWord,
})}
</p>
</div>
);
};
export default SectionDescription;

View file

@ -0,0 +1,24 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { PropertyGroup } from '../../components';
import css from './ListingPage.css';
const SectionFeatures = props => {
const { options, selectedOptions } = props;
return (
<div className={css.sectionFeatures}>
<h2 className={css.featuresTitle}>
<FormattedMessage id="ListingPage.featuresTitle" />
</h2>
<PropertyGroup
id="ListingPage.amenities"
options={options}
selectedOptions={selectedOptions}
twoColumns={true}
/>
</div>
);
};
export default SectionFeatures;

View file

@ -0,0 +1,46 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { InlineTextButton } from '../../components';
import css from './ListingPage.css';
const SectionHeading = props => {
const {
priceTitle,
formattedPrice,
richTitle,
category,
hostLink,
showContactUser,
onContactUser,
} = props;
return (
<div className={css.sectionHeading}>
<div className={css.desktopPriceContainer}>
<div className={css.desktopPriceValue} title={priceTitle}>
{formattedPrice}
</div>
<div className={css.desktopPerUnit}>
<FormattedMessage id="ListingPage.perUnit" />
</div>
</div>
<div className={css.heading}>
<h1 className={css.title}>{richTitle}</h1>
<div className={css.author}>
{category}
<FormattedMessage id="ListingPage.hostedBy" values={{ name: hostLink }} />
{showContactUser ? (
<span className={css.contactWrapper}>
<span className={css.separator}></span>
<InlineTextButton className={css.contactLink} onClick={onContactUser}>
<FormattedMessage id="ListingPage.contactUser" />
</InlineTextButton>
</span>
) : null}
</div>
</div>
</div>
);
};
export default SectionHeading;

View file

@ -0,0 +1,55 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { NamedLink, UserCard, Modal } from '../../components';
import { EnquiryForm } from '../../containers';
import css from './ListingPage.css';
const SectionHost = props => {
const {
title,
listing,
isOwnListing,
authorDisplayName,
onContactUser,
isEnquiryModalOpen,
onCloseEnquiryModal,
sendEnquiryError,
sendEnquiryInProgress,
onSubmitEnquiry,
currentUser,
onManageDisableScrolling,
} = props;
return (
<div id="host" className={css.sectionHost}>
<h2 className={css.yourHostHeading}>
<FormattedMessage id="ListingPage.yourHostHeading" />
</h2>
{isOwnListing ? (
<NamedLink className={css.editProfileLink} name="ProfileSettingsPage">
<FormattedMessage id="ListingPage.editProfileLink" />
</NamedLink>
) : null}
<UserCard user={listing.author} currentUser={currentUser} onContactUser={onContactUser} />
<Modal
id="ListingPage.enquiry"
contentClassName={css.enquiryModalContent}
isOpen={isEnquiryModalOpen}
onClose={onCloseEnquiryModal}
onManageDisableScrolling={onManageDisableScrolling}
>
<EnquiryForm
className={css.enquiryForm}
submitButtonWrapperClassName={css.enquirySubmitButtonWrapper}
listingTitle={title}
authorDisplayName={authorDisplayName}
sendEnquiryError={sendEnquiryError}
onSubmit={onSubmitEnquiry}
inProgress={sendEnquiryInProgress}
/>
</Modal>
</div>
);
};
export default SectionHost;

View file

@ -0,0 +1,75 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { ResponsiveImage, Modal, ImageCarousel } from '../../components';
import ActionBarMaybe from './ActionBarMaybe';
import css from './ListingPage.css';
const SectionImages = props => {
const {
title,
listing,
isOwnListing,
editParams,
handleViewPhotosClick,
imageCarouselOpen,
onImageCarouselClose,
onManageDisableScrolling,
} = props;
const hasImages = listing.images && listing.images.length > 0;
const firstImage = hasImages ? listing.images[0] : null;
// Action bar is wrapped with a div that prevents the click events
// to the parent that would otherwise open the image carousel
const actionBar = listing.id ? (
<div onClick={e => e.stopPropagation()}>
<ActionBarMaybe isOwnListing={isOwnListing} listing={listing} editParams={editParams} />
</div>
) : null;
const viewPhotosButton = hasImages ? (
<button className={css.viewPhotos} onClick={handleViewPhotosClick}>
<FormattedMessage
id="ListingPage.viewImagesButton"
values={{ count: listing.images.length }}
/>
</button>
) : null;
return (
<div className={css.sectionImages}>
<div className={css.threeToTwoWrapper}>
<div className={css.aspectWrapper} onClick={handleViewPhotosClick}>
{actionBar}
<ResponsiveImage
rootClassName={css.rootForImage}
alt={title}
image={firstImage}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
{ name: 'landscape-crop4x', size: '1600w' },
{ name: 'landscape-crop6x', size: '2400w' },
]}
sizes="100vw"
/>
{viewPhotosButton}
</div>
</div>
<Modal
id="ListingPage.imageCarousel"
scrollLayerClassName={css.carouselModalScrollLayer}
containerClassName={css.carouselModalContainer}
lightCloseButton
isOpen={imageCarouselOpen}
onClose={onImageCarouselClose}
onManageDisableScrolling={onManageDisableScrolling}
>
<ImageCarousel images={listing.images} />
</Modal>
</div>
);
};
export default SectionImages;

View file

@ -17,7 +17,7 @@ const SectionMapMaybe = props => {
}
const address = publicData.location ? publicData.location.address : '';
const classes = classNames(rootClassName || css.locationContainer, className);
const classes = classNames(rootClassName || css.sectionMap, className);
const mapProps = config.coordinates.fuzzy
? { obfuscatedCenter: obfuscatedCoordinates(geolocation, listingId ? listingId.uuid : null) }

View file

@ -0,0 +1,27 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Reviews } from '../../components';
import css from './ListingPage.css';
const SectionReviews = props => {
const { reviews, fetchReviewsError } = props;
const reviewsError = (
<h2 className={css.errorText}>
<FormattedMessage id="ListingPage.reviewsError" />
</h2>
);
return (
<div className={css.sectionReviews}>
<h2 className={css.reviewsHeading}>
<FormattedMessage id="ListingPage.reviewsHeading" values={{ count: reviews.length }} />
</h2>
{fetchReviewsError ? reviewsError : null}
<Reviews reviews={reviews} />
</div>
);
};
export default SectionReviews;

View file

@ -34,300 +34,140 @@ exports[`ListingPage matches snapshot 1`] = `
rootClassName={null}
>
<div>
<SectionImages
editParams={
Object {
"id": "listing1",
"slug": "listing1-title",
"tab": "description",
"type": "edit",
}
}
handleViewPhotosClick={[Function]}
imageCarouselOpen={false}
isOwnListing={false}
listing={
Object {
"attributes": Object {
"deleted": false,
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"publicData": Object {},
"state": "published",
"title": "listing1 title",
},
"author": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user-1 display name",
},
},
"id": UUID {
"uuid": "user-1",
},
"type": "user",
},
"id": UUID {
"uuid": "listing1",
},
"images": Array [],
"type": "listing",
}
}
onImageCarouselClose={[Function]}
onManageDisableScrolling={[Function]}
title="listing1 title"
/>
<div>
<div
onClick={[Function]}
>
<div
onClick={[Function]}
>
<ActionBarMaybe
editParams={
Object {
"id": "listing1",
"slug": "listing1-title",
"tab": "description",
"type": "edit",
<SectionAvatar
params={
Object {
"id": "listing1",
"slug": "listing1-title",
}
}
user={
Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user-1 display name",
},
},
"id": UUID {
"uuid": "user-1",
},
"type": "user",
}
}
/>
<div>
<SectionHeading
category={null}
formattedPrice={55}
hostLink={
<NamedLink
className={undefined}
name="ListingPage"
params={
Object {
"id": "listing1",
"slug": "listing1-title",
}
}
}
isOwnListing={false}
listing={
Object {
"attributes": Object {
"deleted": false,
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"publicData": Object {},
"state": "published",
"title": "listing1 title",
},
"author": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user-1 display name",
},
},
"id": UUID {
"uuid": "user-1",
},
"type": "user",
},
"id": UUID {
"uuid": "listing1",
},
"images": Array [],
"type": "listing",
to={
Object {
"hash": "#host",
}
}
}
/>
</div>
<ResponsiveImage
alt="listing1 title"
className={null}
image={null}
nameSet={
>
user-1 display name
</NamedLink>
}
onContactUser={[Function]}
priceTitle={55}
richTitle={
<span>
listing1
title
</span>
}
showContactUser={true}
/>
<SectionDescription
description="listing1 description"
/>
<SectionFeatures
options={
Array [
Object {
"name": "landscape-crop",
"size": "400w",
"key": "feat1",
"label": "Feat 1",
},
Object {
"name": "landscape-crop2x",
"size": "800w",
"key": "feat2",
"label": "Feat 2",
},
Object {
"name": "landscape-crop4x",
"size": "1600w",
},
Object {
"name": "landscape-crop6x",
"size": "2400w",
"key": "feat3",
"label": "Feat 3",
},
]
}
noImageMessage={null}
rootClassName={null}
sizes="100vw"
/>
</div>
</div>
<InjectIntl(ModalComponent)
id="ListingPage.imageCarousel"
isOpen={false}
lightCloseButton={true}
onClose={[Function]}
onManageDisableScrolling={[Function]}
>
<InjectIntl(ImageCarousel)
images={Array []}
/>
</InjectIntl(ModalComponent)>
<div>
<div>
<NamedLink
name="ListingPage"
params={
Object {
"id": "listing1",
"slug": "listing1-title",
}
}
to={
Object {
"hash": "#host",
}
}
>
<AvatarLarge
disableProfileLink={true}
user={
Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user-1 display name",
},
},
"id": UUID {
"uuid": "user-1",
},
"type": "user",
}
}
/>
</NamedLink>
<NamedLink
name="ListingPage"
params={
Object {
"id": "listing1",
"slug": "listing1-title",
}
}
to={
Object {
"hash": "#host",
}
}
>
<AvatarMedium
disableProfileLink={true}
user={
Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user-1 display name",
},
},
"id": UUID {
"uuid": "user-1",
},
"type": "user",
}
}
/>
</NamedLink>
</div>
<div>
<div>
<div>
<div
title={55}
>
55
</div>
<div>
<FormattedMessage
id="ListingPage.perUnit"
values={Object {}}
/>
</div>
</div>
<div>
<h1>
<span>
listing1
title
</span>
</h1>
<div>
<FormattedMessage
id="ListingPage.hostedBy"
values={
Object {
"name": <NamedLink
className={undefined}
name="ListingPage"
params={
Object {
"id": "listing1",
"slug": "listing1-title",
}
}
to={
Object {
"hash": "#host",
}
}
>
user-1 display name
</NamedLink>,
}
}
/>
<span>
<span>
</span>
<InlineTextButton
onClick={[Function]}
>
<FormattedMessage
id="ListingPage.contactUser"
values={Object {}}
/>
</InlineTextButton>
</span>
</div>
</div>
</div>
<div>
<h2>
<FormattedMessage
id="ListingPage.descriptionTitle"
values={Object {}}
/>
</h2>
<p>
listing1
description
</p>
</div>
<div>
<h2>
<FormattedMessage
id="ListingPage.featuresTitle"
values={Object {}}
/>
</h2>
<PropertyGroup
className={null}
id="ListingPage.amenities"
options={
Array [
Object {
"key": "towels",
"label": "Towels",
},
Object {
"key": "bathroom",
"label": "Bathroom",
},
Object {
"key": "swimming_pool",
"label": "Swimming pool",
},
Object {
"key": "own_drinks",
"label": "Own drinks allowed",
},
Object {
"key": "jacuzzi",
"label": "Jacuzzi",
},
Object {
"key": "audiovisual_entertainment",
"label": "Audiovisual entertainment",
},
Object {
"key": "barbeque",
"label": "Barbeque",
},
Object {
"key": "own_food_allowed",
"label": "Own food allowed",
},
]
}
rootClassName={null}
selectedOptions={Array []}
twoColumns={true}
/>
</div>
<SectionRulesMaybe
className={null}
publicData={Object {}}
@ -349,56 +189,52 @@ exports[`ListingPage matches snapshot 1`] = `
publicData={Object {}}
rootClassName={null}
/>
<div>
<h2>
<FormattedMessage
id="ListingPage.reviewsHeading"
values={
Object {
"count": 0,
}
}
/>
</h2>
<InjectIntl(ReviewsComponent)
reviews={Array []}
/>
</div>
<div
id="host"
>
<h2>
<FormattedMessage
id="ListingPage.yourHostHeading"
values={Object {}}
/>
</h2>
<UserCard
className={null}
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "user-2@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "user-2 abbreviated name",
"displayName": "user-2 display name",
"firstName": "user-2 first name",
"lastName": "user-2 last name",
},
"stripeConnected": true,
<SectionReviews
fetchReviewsError={null}
reviews={Array []}
/>
<SectionHost
authorDisplayName="user-1 display name"
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "user-2@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "user-2 abbreviated name",
"displayName": "user-2 display name",
"firstName": "user-2 first name",
"lastName": "user-2 last name",
},
"id": UUID {
"uuid": "user-2",
},
"type": "currentUser",
}
"stripeConnected": true,
},
"id": UUID {
"uuid": "user-2",
},
"type": "currentUser",
}
onContactUser={[Function]}
rootClassName={null}
user={
Object {
}
isEnquiryModalOpen={false}
isOwnListing={false}
listing={
Object {
"attributes": Object {
"deleted": false,
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"publicData": Object {},
"state": "published",
"title": "listing1 title",
},
"author": Object {
"attributes": Object {
"banned": false,
"profile": Object {
@ -410,114 +246,88 @@ exports[`ListingPage matches snapshot 1`] = `
"uuid": "user-1",
},
"type": "user",
}
}
/>
<InjectIntl(ModalComponent)
id="ListingPage.enquiry"
isOpen={false}
onClose={[Function]}
onManageDisableScrolling={[Function]}
>
<ReduxForm
authorDisplayName="user-1 display name"
inProgress={false}
listingTitle="listing1 title"
onSubmit={[Function]}
sendEnquiryError={null}
/>
</InjectIntl(ModalComponent)>
</div>
</div>
<withViewport(ModalInMobileComponent)
id="BookingDatesFormInModal"
isModalOpenOnMobile={false}
onClose={[Function]}
onManageDisableScrolling={[Function]}
showAsModalMaxWidth={1023}
>
<div>
<h1>
<span>
listing1
title
</span>
</h1>
<div>
<span>
<FormattedMessage
id="ListingPage.hostedBy"
values={
Object {
"name": "user-1 display name",
}
}
/>
</span>
</div>
</div>
<div>
<h2>
<FormattedMessage
id="ListingPage.bookingTitle"
values={
Object {
"title": <span>
listing1
title
</span>,
}
}
/>
</h2>
<div>
<FormattedMessage
id="ListingPage.bookingHelp"
values={Object {}}
/>
</div>
</div>
<BookingDatesForm
isOwnListing={false}
onSubmit={[Function]}
price={
Money {
"amount": 5500,
"currency": "USD",
},
"id": UUID {
"uuid": "listing1",
},
"images": Array [],
"type": "listing",
}
}
unitType="line-item/night"
onCloseEnquiryModal={[Function]}
onContactUser={[Function]}
onManageDisableScrolling={[Function]}
onSubmitEnquiry={[Function]}
sendEnquiryError={null}
sendEnquiryInProgress={false}
title="listing1 title"
/>
</withViewport(ModalInMobileComponent)>
<div>
<div>
<div
title={55}
>
55
</div>
<div>
<FormattedMessage
id="ListingPage.perUnit"
values={Object {}}
/>
</div>
</div>
<Button
className={null}
disabled={false}
inProgress={false}
onClick={[Function]}
ready={false}
rootClassName={null}
>
ListingPage.ctaButtonMessage
</Button>
</div>
<SectionBooking
authorDisplayName="user-1 display name"
formattedPrice={55}
handleBookButtonClick={[Function]}
handleBookingSubmit={[Function]}
handleMobileBookModalClose={[Function]}
isBook={false}
isClosed={false}
isOwnListing={false}
listing={
Object {
"attributes": Object {
"deleted": false,
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"publicData": Object {},
"state": "published",
"title": "listing1 title",
},
"author": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user-1 display name",
},
},
"id": UUID {
"uuid": "user-1",
},
"type": "user",
},
"id": UUID {
"uuid": "listing1",
},
"images": Array [],
"type": "listing",
}
}
onManageDisableScrolling={[Function]}
price={
Money {
"amount": 5500,
"currency": "USD",
}
}
priceTitle={55}
richTitle={
<span>
listing1
title
</span>
}
unitType="line-item/night"
/>
</div>
</div>
</LayoutWrapperMain>