ProfilePage page with user bio

This commit is contained in:
Kimmo Puputti 2017-10-31 16:40:34 +02:00
parent c424054e73
commit 689697fc4b
9 changed files with 410 additions and 28 deletions

View file

@ -38,7 +38,7 @@ describe('Application', () => {
'/s': 'Search results for map search',
'/l/listing-title-slug/1234': 'Loading listing…',
'/l/1234': 'Loading listing…',
'/u/1234': 'Profile page with display name: 1234',
'/u/1234': 'Profile page',
'/login': 'Log in',
'/signup': 'Sign up',
'/recover-password': 'Request a new password',

View file

@ -0,0 +1,121 @@
@import '../../marketplace.css';
.loading,
.error {
margin-top: 5px;
@media (--viewportMedium) {
margin-top: 4px;
}
@media (--viewportLarge) {
margin-top: 3px;
}
}
.error {
color: var(--failColor);
}
.aside {
box-shadow: none;
}
.asideContent {
width: 100%;
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--matterColorNegative);
@media (--viewportLarge) {
flex-direction: column;
border-bottom: none;
}
}
.avatar {
margin: 30px 26px 29px 0;
flex-shrink: 0;
@media (--viewportLarge) {
margin: 0 96px 44px 0;
}
}
.mobileHeading {
flex-shrink: 0;
margin: 47px 0 0 0;
@media (--viewportMedium) {
margin: 49px 0 0 0;
}
@media (--viewportLarge) {
display: none;
}
}
.editLinkMobile {
margin-top: 17px;
/* Pull the link to the end of the container */
margin-left: auto;
@media (--viewportMedium) {
margin-top: 20px;
}
@media (--viewportLarge) {
display: none;
}
}
.editLinkDesktop {
display: none;
@media (--viewportLarge) {
display: inline;
}
}
.desktopHeading {
display: none;
@media (--viewportLarge) {
display: block;
margin: 4px 0 23px 0;
}
}
.bio {
/* Preserve newlines, but collapse other whitespace */
white-space: pre-line;
max-width: 563px;
margin: 5px 0 24px 0;
@media (--viewportMedium) {
margin: 4px 0 51px 0;
}
@media (--viewportLarge) {
margin: 0 0 56px 0;
}
}
.listingsContainer {
border-top: 1px solid var(--matterColorNegative);
}
.listingsTitle {
@apply --marketplaceH3FontStyles;
color: var(--matterColorAnti);
@media (--viewportLarge) {
margin-top: 19px;
}
}
.withBioMissingAbove {
border-top: none;
@media (--viewportLarge) {
border-top: 1px solid var(--matterColorNegative);
}
}

View file

@ -0,0 +1,64 @@
import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck';
import { fetchCurrentUser } from '../../ducks/user.duck';
import { storableError } from '../../util/errors';
// ================ Action types ================ //
export const SHOW_USER_REQUEST = 'app/ProfilePage/SHOW_USER_REQUEST';
export const SHOW_USER_SUCCESS = 'app/ProfilePage/SHOW_USER_SUCCESS';
export const SHOW_USER_ERROR = 'app/ProfilePage/SHOW_USER_ERROR';
// ================ Reducer ================ //
const initialState = {
userId: null,
userShowInProgress: false,
userShowError: null,
};
export default function profilePageReducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
case SHOW_USER_REQUEST:
return { ...state, userShowInProgress: true, userShowError: null, userId: payload.userId };
case SHOW_USER_SUCCESS:
return { ...state, userShowInProgress: false };
case SHOW_USER_ERROR:
return { ...state, userShowInProgress: false, userShowError: payload };
default:
return state;
}
}
// ================ Action creators ================ //
export const showUserRequest = userId => ({
type: SHOW_USER_REQUEST,
payload: { userId },
});
export const showUserSuccess = () => ({
type: SHOW_USER_SUCCESS,
});
export const showUserError = e => ({
type: SHOW_USER_ERROR,
error: true,
payload: e,
});
// ================ Thunks ================ //
export const showUser = userId => (dispatch, getState, sdk) => {
dispatch(showUserRequest(userId));
dispatch(fetchCurrentUser());
return sdk.users
.show({ id: userId, include: ['profileImage'] })
.then(response => {
dispatch(addMarketplaceEntities(response));
dispatch(showUserSuccess());
return response;
})
.catch(e => showUserError(storableError(e)));
};

View file

@ -1,47 +1,170 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { compose } from 'redux';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { types } from '../../util/sdkLoader';
import * as propTypes from '../../util/propTypes';
import { ensureCurrentUser, ensureUser } from '../../util/data';
import { isScrollingDisabled } from '../../ducks/UI.duck';
import { Page } from '../../components';
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
import {
Page,
LayoutSideNavigation,
LayoutWrapperMain,
LayoutWrapperSideNav,
LayoutWrapperTopbar,
LayoutWrapperFooter,
Footer,
AvatarLarge,
NamedLink,
} from '../../components';
import { TopbarContainer } from '../../containers';
import { showUser } from './ProfilePage.duck';
import css from './ProfilePage.css';
const { UUID } = types;
export const ProfilePageComponent = props => {
const { logoutError, params, scrollingDisabled } = props;
const {
logoutError,
scrollingDisabled,
currentUser,
user,
userShowInProgress,
userShowError,
} = 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 listings = [];
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} />
<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>
</div>
) : null}
</div>
);
let content;
if (userShowError) {
content = (
<p className={css.error}>
<FormattedMessage id="ProfilePage.loadingDataFailed" />
</p>
);
} else if (userShowInProgress) {
content = (
<p className={css.loading}>
<FormattedMessage id="ProfilePage.loadingData" />
</p>
);
} else {
content = mainContent;
}
const schemaTitle = 'Profile page';
return (
<Page
logoutError={logoutError}
title={`Profile page with display name: ${params.displayName}`}
scrollingDisabled={scrollingDisabled}
>
<TopbarContainer />
<Page logoutError={logoutError} scrollingDisabled={scrollingDisabled} title={schemaTitle}>
<LayoutSideNavigation>
<LayoutWrapperTopbar>
<TopbarContainer currentPage="ProfilePage" />
</LayoutWrapperTopbar>
<LayoutWrapperSideNav className={css.aside}>{asideContent}</LayoutWrapperSideNav>
<LayoutWrapperMain>{content}</LayoutWrapperMain>
<LayoutWrapperFooter>
<Footer />
</LayoutWrapperFooter>
</LayoutSideNavigation>
</Page>
);
};
ProfilePageComponent.defaultProps = {
logoutError: null,
currentUser: null,
user: null,
userShowError: null,
};
const { bool, shape, string } = PropTypes;
const { bool } = PropTypes;
ProfilePageComponent.propTypes = {
logoutError: propTypes.error,
params: shape({ displayName: string.isRequired }).isRequired,
scrollingDisabled: bool.isRequired,
currentUser: propTypes.currentUser,
user: propTypes.user,
userShowInProgress: bool.isRequired,
userShowError: propTypes.error,
};
const mapStateToProps = state => {
// Page needs logoutError
const { logoutError } = state.Auth;
const { currentUser } = state.user;
const { userId, userShowInProgress, userShowError } = state.ProfilePage;
const matches = getMarketplaceEntities(state, [{ type: 'user', id: userId }]);
const user = matches.length === 1 ? matches[0] : null;
return {
logoutError,
scrollingDisabled: isScrollingDisabled(state),
currentUser,
user,
userShowInProgress,
userShowError,
};
};
const ProfilePage = compose(connect(mapStateToProps))(ProfilePageComponent);
ProfilePage.loadData = params => {
const id = new UUID(params.id);
return showUser(id);
};
export default ProfilePage;

View file

@ -1,5 +1,6 @@
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import { createUser } from '../../util/test-data';
import { ProfilePageComponent } from './ProfilePage';
const noop = () => null;
@ -8,17 +9,9 @@ describe('ProfilePage', () => {
it('matches snapshot', () => {
const tree = renderShallow(
<ProfilePageComponent
params={{ displayName: 'most-awesome-shop' }}
history={{ push: noop }}
location={{ search: '' }}
scrollingDisabled={false}
authInProgress={false}
currentUserHasListings={false}
isAuthenticated={false}
onLogout={noop}
onManageDisableScrolling={noop}
sendVerificationEmailInProgress={false}
onResendVerificationEmail={noop}
user={createUser('test-user')}
userShowInProgress={false}
/>
);
expect(tree).toMatchSnapshot();

View file

@ -4,8 +4,79 @@ exports[`ProfilePage matches snapshot 1`] = `
<Page
logoutError={null}
scrollingDisabled={false}
title="Profile page with display name: most-awesome-shop"
title="Profile page"
>
<withRouter(Connect(TopbarContainerComponent)) />
<LayoutSideNavigation
className={null}
containerClassName={null}
rootClassName={null}
>
<LayoutWrapperTopbar
className={null}
rootClassName={null}
>
<withRouter(Connect(TopbarContainerComponent))
currentPage="ProfilePage"
/>
</LayoutWrapperTopbar>
<LayoutWrapperSideNav
className={null}
rootClassName={null}
tabs={null}
>
<div>
<AvatarLarge
user={
Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "test-user abbreviated name",
"displayName": "test-user display name",
},
},
"id": UUID {
"uuid": "test-user",
},
"type": "user",
}
}
/>
<h1>
<FormattedMessage
id="ProfilePage.mobileHeading"
values={
Object {
"name": "test-user display name",
}
}
/>
</h1>
</div>
</LayoutWrapperSideNav>
<LayoutWrapperMain
className={null}
rootClassName={null}
>
<div>
<h1>
<FormattedMessage
id="ProfilePage.desktopHeading"
values={
Object {
"name": "test-user display name",
}
}
/>
</h1>
</div>
</LayoutWrapperMain>
<LayoutWrapperFooter
className={null}
rootClassName={null}
>
<InjectIntl(Footer) />
</LayoutWrapperFooter>
</LayoutSideNavigation>
</Page>
`;

View file

@ -13,6 +13,7 @@ import OrderPage from './OrderPage/OrderPage.duck';
import PasswordChangePage from './PasswordChangePage/PasswordChangePage.duck';
import PasswordRecoveryPage from './PasswordRecoveryPage/PasswordRecoveryPage.duck';
import PasswordResetPage from './PasswordResetPage/PasswordResetPage.duck';
import ProfilePage from './ProfilePage/ProfilePage.duck';
import ProfileSettingsPage from './ProfileSettingsPage/ProfileSettingsPage.duck';
import SalePage from './SalePage/SalePage.duck';
import SearchPage from './SearchPage/SearchPage.duck';
@ -28,6 +29,7 @@ export {
PasswordChangePage,
PasswordRecoveryPage,
PasswordResetPage,
ProfilePage,
ProfileSettingsPage,
SalePage,
SearchPage,

View file

@ -133,9 +133,10 @@ const routeConfiguration = () => {
component: RedirectToLandingPage,
},
{
path: '/u/:displayName',
path: '/u/:id',
name: 'ProfilePage',
component: props => <ProfilePage {...props} />,
loadData: ProfilePage.loadData,
},
{
path: '/profile-settings',

View file

@ -46,7 +46,7 @@
"CheckoutPage.initiateOrderAmountTooLow": "Unfortunately we're not able to process this payment, since the payment amount is too low. Please contact the administrators.",
"CheckoutPage.initiateOrderError": "Payment request failed. Please go back to {listingLink} and try again. If the error persists, try refreshing the page or contacting the marketplace admin.",
"CheckoutPage.listingNotFoundError": "Unfortunately the listing is not available anymore.",
"CheckoutPage.loadingData": "Loading checkout data...",
"CheckoutPage.loadingData": "Loading checkout data",
"CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.",
"CheckoutPage.paymentTitle": "Payment",
"CheckoutPage.priceBreakdownTitle": "Booking breakdown",
@ -317,10 +317,10 @@
"PasswordRecoveryPage.forgotPasswordTitle": "Forgot your password?",
"PasswordRecoveryPage.resendEmailInfo": "Didn't get the email? {resendEmailLink}",
"PasswordRecoveryPage.resendEmailLinkText": "Send another email.",
"PasswordRecoveryPage.resendingEmailInfo": "Resending instructions...",
"PasswordRecoveryPage.resendingEmailInfo": "Resending instructions",
"PasswordRecoveryPage.title": "Request a new password",
"PasswordResetForm.passwordLabel": "Your new password",
"PasswordResetForm.passwordPlaceholder": "Enter your new password...",
"PasswordResetForm.passwordPlaceholder": "Enter your new password",
"PasswordResetForm.passwordRequired": "This field is required",
"PasswordResetForm.passwordTooLong": "Password should be at most {maxLength} characters",
"PasswordResetForm.passwordTooShort": "Password should be at least {minLength} characters",
@ -387,6 +387,13 @@
"PrivacyPolicyPage.privacyTabTitle": "Privacy Policy",
"PrivacyPolicyPage.schemaTitle": "Privacy Policy | {siteTitle}",
"PrivacyPolicyPage.tosTabTitle": "Terms of Service",
"ProfilePage.desktopHeading": "Hello, I'm {name}.",
"ProfilePage.editProfileLinkDesktop": "Edit profile",
"ProfilePage.editProfileLinkMobile": "Edit",
"ProfilePage.listingsTitle": "My saunas ({count})",
"ProfilePage.loadingData": "Loading user data…",
"ProfilePage.loadingDataFailed": "Whoopsie, something went wrong - please try again.",
"ProfilePage.mobileHeading": "{name}",
"ProfileSettingsForm.addYourProfilePicture": "+ Add your profile picture…",
"ProfileSettingsForm.addYourProfilePictureMobile": "+ Add",
"ProfileSettingsForm.changeAvatar": "Change",
@ -445,8 +452,8 @@
"SectionHero.subTitle": "The largest online community to rent saunas in Finland.",
"SectionHero.title": "Book saunas everywhere.",
"SectionLocations.listingsInLocation": "Saunas in {location}",
"SectionLocations.title": "We have wooden saunas, electric saunas and even tent saunas.",
"SectionLocations.subtitle": "We have more than 1000 saunas around Finland. Here are some of our most popular locations.",
"SectionLocations.title": "We have wooden saunas, electric saunas and even tent saunas.",
"SignupForm.emailInvalid": "A valid email address is required",
"SignupForm.emailLabel": "Email",
"SignupForm.emailPlaceholder": "john.doe@example.com",