Add reviews to ProfilePage

This commit is contained in:
Hannu Lyytikainen 2017-12-13 14:21:17 +02:00
parent a7125173c5
commit 893427a78e
7 changed files with 367 additions and 122 deletions

View file

@ -1,5 +1,5 @@
import TabNavHorizontal from './TabNavHorizontal';
import { TYPE_BUTTON, TYPE_LINK } from './TabNavHorizontal';
import { TAB_TYPE_BUTTON, TAB_TYPE_LINK } from './TabNavHorizontal';
const selfLinkProps = {
name: 'StyleguideComponent',
@ -13,7 +13,7 @@ export const LinkTabs = {
{ text: 'Normal', linkProps: selfLinkProps },
{ text: 'Selected', linkProps: selfLinkProps, selected: true },
],
type: TYPE_LINK,
type: TAB_TYPE_LINK,
},
group: 'navigation',
};
@ -24,7 +24,7 @@ export const ButtonTabs = {
component: TabNavHorizontal,
props: {
tabs: [{ text: 'Normal', onClick: noop }, { text: 'Selected', onClick: noop, selected: true }],
type: TYPE_BUTTON,
type: TAB_TYPE_BUTTON,
},
group: 'navigation',
};

View file

@ -21,8 +21,8 @@ import css from './TabNavHorizontal.css';
export const LIGHT_SKIN = 'light';
export const DARK_SKIN = 'dark';
export const TYPE_LINK = 'link';
export const TYPE_BUTTON = 'button';
export const TAB_TYPE_LINK = 'link';
export const TAB_TYPE_BUTTON = 'button';
const { arrayOf, bool, func, node, object, oneOf, string } = PropTypes;
@ -105,7 +105,7 @@ LinkTab.propTypes = {
const TabNavHorizontal = props => {
const { className, rootClassName, tabRootClassName, tabs, skin, type } = props;
const isDark = skin === DARK_SKIN;
const isButton = type === TYPE_BUTTON;
const isButton = type === TAB_TYPE_BUTTON;
const classes = classNames(rootClassName || css.root, { [css.darkSkin]: isDark }, className);
const tabClasses = tabRootClassName || css.tab;
return (
@ -128,7 +128,7 @@ TabNavHorizontal.defaultProps = {
tabRootClassName: null,
tabClassName: null,
skin: LIGHT_SKIN,
type: TYPE_LINK,
type: TAB_TYPE_LINK,
};
TabNavHorizontal.propTypes = {
@ -137,7 +137,7 @@ TabNavHorizontal.propTypes = {
tabRootClassName: string,
tabs: arrayOf(object).isRequired,
skin: oneOf([LIGHT_SKIN, DARK_SKIN]),
type: oneOf([TYPE_LINK, TYPE_BUTTON]),
type: oneOf([TAB_TYPE_LINK, TAB_TYPE_BUTTON]),
};
export default TabNavHorizontal;

View file

@ -99,7 +99,14 @@
}
.listingsContainer {
@apply --clearfix;
border-top: 1px solid var(--matterColorNegative);
border-bottom: 1px solid var(--matterColorNegative);
padding-bottom: 23px;
@media (--viewportMedium) {
padding-bottom: 58px;
}
}
.listingsTitle {
@ -158,3 +165,33 @@
}
}
}
.mobileReviews {
@media (--viewportMedium) {
display: none;
}
}
.mobileReviewsTitle {
@apply --marketplaceH3FontStyles;
color: var(--matterColorAnti);
margin: 24px 0;
}
.desktopReviews {
display: none;
@media (--viewportMedium) {
display: block;
margin-top: 28px;
}
}
.desktopReviewsTitle {
@apply --marketplaceH3FontStyles;
margin-bottom: 0;
}
.desktopReviewsTabNav {
margin-bottom: 32px;
}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
import { compose } from 'redux';
@ -20,7 +20,10 @@ import {
AvatarLarge,
NamedLink,
ListingCard,
Reviews,
TabNavHorizontal,
} from '../../components';
import { TAB_TYPE_BUTTON } from '../../components/TabNavHorizontal/TabNavHorizontal';
import { TopbarContainer } from '../../containers';
import { loadData } from './ProfilePage.duck';
import config from '../../config';
@ -29,127 +32,241 @@ import css from './ProfilePage.css';
const { UUID } = types;
export const ProfilePageComponent = props => {
const {
scrollingDisabled,
currentUser,
user,
userShowError,
queryListingsError,
listings,
intl,
} = props;
const ensuredCurrentUser = ensureCurrentUser(currentUser);
const profileUser = ensureUser(user);
const isCurrentUser =
ensuredCurrentUser.id && profileUser.id && ensuredCurrentUser.id.uuid === profileUser.id.uuid;
const displayName = profileUser.attributes.profile.displayName;
const bio = profileUser.attributes.profile.bio;
const hasBio = !!bio;
const hasListings = listings.length > 0;
export class ProfilePageComponent extends Component {
constructor(props) {
super(props);
const editLinkMobile = isCurrentUser ? (
<NamedLink className={css.editLinkMobile} name="ProfileSettingsPage">
<FormattedMessage id="ProfilePage.editProfileLinkMobile" />
</NamedLink>
) : null;
const editLinkDesktop = isCurrentUser ? (
<NamedLink className={css.editLinkDesktop} name="ProfileSettingsPage">
<FormattedMessage id="ProfilePage.editProfileLinkDesktop" />
</NamedLink>
) : null;
this.state = {
reviews: propTypes.REVIEW_TYPE_OF_PROVIDER,
};
const asideContent = (
<div className={css.asideContent}>
<AvatarLarge className={css.avatar} user={user} disableProfileLink />
<h1 className={css.mobileHeading}>
{displayName ? (
<FormattedMessage id="ProfilePage.mobileHeading" values={{ name: displayName }} />
) : null}
</h1>
{editLinkMobile}
{editLinkDesktop}
</div>
);
const listingsContainerClasses = classNames(css.listingsContainer, {
[css.withBioMissingAbove]: !hasBio,
});
const mainContent = (
<div>
<h1 className={css.desktopHeading}>
<FormattedMessage id="ProfilePage.desktopHeading" values={{ name: displayName }} />
</h1>
{hasBio ? <p className={css.bio}>{bio}</p> : null}
{hasListings ? (
<div className={listingsContainerClasses}>
<h2 className={css.listingsTitle}>
<FormattedMessage id="ProfilePage.listingsTitle" values={{ count: listings.length }} />
</h2>
<ul className={css.listings}>
{listings.map(l => (
<li className={css.listing} key={l.id.uuid}>
<ListingCard listing={l} />
</li>
))}
</ul>
</div>
) : null}
</div>
);
let content;
if (userShowError || queryListingsError) {
content = (
<p className={css.error}>
<FormattedMessage id="ProfilePage.loadingDataFailed" />
</p>
);
} else {
content = mainContent;
this.showOfProviderReviews = this.showOfProviderReviews.bind(this);
this.showOfCustomerReviews = this.showOfCustomerReviews.bind(this);
}
const schemaTitle = intl.formatMessage(
{
id: 'ProfilePage.schemaTitle',
},
{
name: displayName,
siteTitle: config.siteTitle,
}
);
showOfProviderReviews() {
this.setState({
reviews: propTypes.REVIEW_TYPE_OF_PROVIDER,
});
}
return (
<Page
scrollingDisabled={scrollingDisabled}
title={schemaTitle}
schema={{
'@context': 'http://schema.org',
'@type': 'ProfilePage',
name: schemaTitle,
}}
>
<LayoutSideNavigation>
<LayoutWrapperTopbar>
<TopbarContainer currentPage="ProfilePage" />
</LayoutWrapperTopbar>
<LayoutWrapperSideNav className={css.aside}>{asideContent}</LayoutWrapperSideNav>
<LayoutWrapperMain>{content}</LayoutWrapperMain>
<LayoutWrapperFooter>
<Footer />
</LayoutWrapperFooter>
</LayoutSideNavigation>
</Page>
);
};
showOfCustomerReviews() {
this.setState({
reviews: propTypes.REVIEW_TYPE_OF_CUSTOMER,
});
}
render() {
const {
scrollingDisabled,
currentUser,
user,
userShowError,
queryListingsError,
listings,
reviews,
queryReviewsError,
intl,
} = this.props;
const ensuredCurrentUser = ensureCurrentUser(currentUser);
const profileUser = ensureUser(user);
const isCurrentUser =
ensuredCurrentUser.id && profileUser.id && ensuredCurrentUser.id.uuid === profileUser.id.uuid;
const displayName = profileUser.attributes.profile.displayName;
const bio = profileUser.attributes.profile.bio;
const hasBio = !!bio;
const hasListings = listings.length > 0;
const editLinkMobile = isCurrentUser ? (
<NamedLink className={css.editLinkMobile} name="ProfileSettingsPage">
<FormattedMessage id="ProfilePage.editProfileLinkMobile" />
</NamedLink>
) : null;
const editLinkDesktop = isCurrentUser ? (
<NamedLink className={css.editLinkDesktop} name="ProfileSettingsPage">
<FormattedMessage id="ProfilePage.editProfileLinkDesktop" />
</NamedLink>
) : null;
const asideContent = (
<div className={css.asideContent}>
<AvatarLarge className={css.avatar} user={user} disableProfileLink />
<h1 className={css.mobileHeading}>
{displayName ? (
<FormattedMessage id="ProfilePage.mobileHeading" values={{ name: displayName }} />
) : null}
</h1>
{editLinkMobile}
{editLinkDesktop}
</div>
);
const listingsContainerClasses = classNames(css.listingsContainer, {
[css.withBioMissingAbove]: !hasBio,
});
const reviewsError = (
<p className={css.error}>
<FormattedMessage id="ProfilePage.loadingReviewsFailed" />
</p>
);
const reviewsOfProvider = reviews.filter(
r => r.attributes.type === propTypes.REVIEW_TYPE_OF_PROVIDER
);
const reviewsOfCustomer = reviews.filter(
r => r.attributes.type === propTypes.REVIEW_TYPE_OF_CUSTOMER
);
const mobileReviews = (
<div className={css.mobileReviews}>
<h2 className={css.mobileReviewsTitle}>
<FormattedMessage
id="ProfilePage.reviewsOfProviderTitle"
values={{ count: reviewsOfProvider.length }}
/>
</h2>
{queryReviewsError ? reviewsError : null}
<Reviews reviews={reviewsOfProvider} />
<h2 className={css.mobileReviewsTitle}>
<FormattedMessage
id="ProfilePage.reviewsOfCustomerTitle"
values={{ count: reviewsOfCustomer.length }}
/>
</h2>
{queryReviewsError ? reviewsError : null}
<Reviews reviews={reviewsOfCustomer} />
</div>
);
const desktopReviewTabs = [
{
text: (
<h3 className={css.desktopReviewsTitle}>
<FormattedMessage
id="ProfilePage.reviewsOfProviderTitle"
values={{ count: reviewsOfProvider.length }}
/>
</h3>
),
selected: this.state.reviews === propTypes.REVIEW_TYPE_OF_PROVIDER,
onClick: this.showOfProviderReviews,
},
{
text: (
<h3 className={css.desktopReviewsTitle}>
<FormattedMessage
id="ProfilePage.reviewsOfCustomerTitle"
values={{ count: reviewsOfCustomer.length }}
/>
</h3>
),
selected: this.state.reviews === propTypes.REVIEW_TYPE_OF_CUSTOMER,
onClick: this.showOfCustomerReviews,
},
];
const desktopReviews = (
<div className={css.desktopReviews}>
<TabNavHorizontal
className={css.desktopReviewsTabNav}
tabs={desktopReviewTabs}
type={TAB_TYPE_BUTTON}
/>
{queryReviewsError ? reviewsError : null}
{this.state.reviews === propTypes.REVIEW_TYPE_OF_PROVIDER ? (
<Reviews reviews={reviewsOfProvider} />
) : (
<Reviews reviews={reviewsOfCustomer} />
)}
</div>
);
const mainContent = (
<div>
<h1 className={css.desktopHeading}>
<FormattedMessage id="ProfilePage.desktopHeading" values={{ name: displayName }} />
</h1>
{hasBio ? <p className={css.bio}>{bio}</p> : null}
{hasListings ? (
<div className={listingsContainerClasses}>
<h2 className={css.listingsTitle}>
<FormattedMessage
id="ProfilePage.listingsTitle"
values={{ count: listings.length }}
/>
</h2>
<ul className={css.listings}>
{listings.map(l => (
<li className={css.listing} key={l.id.uuid}>
<ListingCard listing={l} />
</li>
))}
</ul>
</div>
) : null}
{mobileReviews}
{desktopReviews}
</div>
);
let content;
if (userShowError || queryListingsError) {
content = (
<p className={css.error}>
<FormattedMessage id="ProfilePage.loadingDataFailed" />
</p>
);
} else {
content = mainContent;
}
const schemaTitle = intl.formatMessage(
{
id: 'ProfilePage.schemaTitle',
},
{
name: displayName,
siteTitle: config.siteTitle,
}
);
return (
<Page
scrollingDisabled={scrollingDisabled}
title={schemaTitle}
schema={{
'@context': 'http://schema.org',
'@type': 'ProfilePage',
name: schemaTitle,
}}
>
<LayoutSideNavigation>
<LayoutWrapperTopbar>
<TopbarContainer currentPage="ProfilePage" />
</LayoutWrapperTopbar>
<LayoutWrapperSideNav className={css.aside}>{asideContent}</LayoutWrapperSideNav>
<LayoutWrapperMain>{content}</LayoutWrapperMain>
<LayoutWrapperFooter>
<Footer />
</LayoutWrapperFooter>
</LayoutSideNavigation>
</Page>
);
}
}
ProfilePageComponent.defaultProps = {
currentUser: null,
user: null,
userShowError: null,
queryListingsError: null,
reviews: [],
queryReviewsError: null,
};
const { bool, arrayOf } = PropTypes;
@ -161,6 +278,8 @@ ProfilePageComponent.propTypes = {
userShowError: propTypes.error,
queryListingsError: propTypes.error,
listings: arrayOf(propTypes.listing).isRequired,
reviews: arrayOf(propTypes.review),
queryReviewsError: propTypes.error,
// from injectIntl
intl: intlShape.isRequired,
@ -168,7 +287,14 @@ ProfilePageComponent.propTypes = {
const mapStateToProps = state => {
const { currentUser } = state.user;
const { userId, userShowError, queryListingsError, userListingRefs } = state.ProfilePage;
const {
userId,
userShowError,
queryListingsError,
userListingRefs,
reviews,
queryReviewsError,
} = state.ProfilePage;
const userMatches = getMarketplaceEntities(state, [{ type: 'user', id: userId }]);
const user = userMatches.length === 1 ? userMatches[0] : null;
const listings = getMarketplaceEntities(state, userListingRefs);
@ -179,6 +305,8 @@ const mapStateToProps = state => {
userShowError,
queryListingsError,
listings,
reviews,
queryReviewsError,
};
};

View file

@ -76,6 +76,79 @@ exports[`ProfilePage matches snapshot 1`] = `
}
/>
</h1>
<div>
<h2>
<FormattedMessage
id="ProfilePage.reviewsOfProviderTitle"
values={
Object {
"count": 0,
}
}
/>
</h2>
<InjectIntl(ReviewsComponent)
reviews={Array []}
/>
<h2>
<FormattedMessage
id="ProfilePage.reviewsOfCustomerTitle"
values={
Object {
"count": 0,
}
}
/>
</h2>
<InjectIntl(ReviewsComponent)
reviews={Array []}
/>
</div>
<div>
<TabNavHorizontal
className={null}
rootClassName={null}
skin="light"
tabClassName={null}
tabRootClassName={null}
tabs={
Array [
Object {
"onClick": [Function],
"selected": true,
"text": <h3>
<FormattedMessage
id="ProfilePage.reviewsOfProviderTitle"
values={
Object {
"count": 0,
}
}
/>
</h3>,
},
Object {
"onClick": [Function],
"selected": false,
"text": <h3>
<FormattedMessage
id="ProfilePage.reviewsOfCustomerTitle"
values={
Object {
"count": 0,
}
}
/>
</h3>,
},
]
}
type="button"
/>
<InjectIntl(ReviewsComponent)
reviews={Array []}
/>
</div>
</div>
</LayoutWrapperMain>
<LayoutWrapperFooter

View file

@ -239,7 +239,7 @@
"ListingPage.ownClosedListing": "Your listing has been closed and can't be booked.",
"ListingPage.ownListing": "This is your own listing.",
"ListingPage.perNight": "per night",
"ListingPage.reviewsError": "Fetching reviews failed.",
"ListingPage.reviewsError": "Loading reviews failed.",
"ListingPage.reviewsHeading": "Reviews ({count})",
"ListingPage.schemaTitle": "{title} - {price} | {siteTitle}",
"ListingPage.viewImagesButton": "View photos ({count})",
@ -417,7 +417,10 @@
"ProfilePage.editProfileLinkMobile": "Edit",
"ProfilePage.listingsTitle": "My saunas ({count})",
"ProfilePage.loadingDataFailed": "Whoops, something went wrong. Please try again.",
"ProfilePage.loadingReviewsFailed": "Loading reviews failed.",
"ProfilePage.mobileHeading": "{name}",
"ProfilePage.reviewsOfProviderTitle": "Reviews from Guests ({count})",
"ProfilePage.reviewsOfCustomerTitle": "Reviews from Hosts ({count})",
"ProfilePage.schemaTitle": "{name} | {siteTitle}",
"ProfileSettingsForm.addYourProfilePicture": "+ Add your profile picture…",
"ProfileSettingsForm.addYourProfilePictureMobile": "+ Add",

View file

@ -261,6 +261,10 @@ export const isReviewTransition = transition => {
// Possible amount of stars in a review
export const REVIEW_RATINGS = [1, 2, 3, 4, 5];
// Review types: review of a provider and of a customer
export const REVIEW_TYPE_OF_PROVIDER = 'ofProvider';
export const REVIEW_TYPE_OF_CUSTOMER = 'ofCustomer';
// A review on a user
export const review = shape({
id: uuid.isRequired,
@ -269,7 +273,7 @@ export const review = shape({
content: string,
rating: oneOf(REVIEW_RATINGS),
state: string.isRequired,
type: string.isRequired,
type: oneOf([REVIEW_TYPE_OF_PROVIDER, REVIEW_TYPE_OF_CUSTOMER]).isRequired,
}).isRequired,
author: user,
subject: user,