diff --git a/src/app.test.js b/src/app.test.js
index e4e063ed..696b200e 100644
--- a/src/app.test.js
+++ b/src/app.test.js
@@ -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',
diff --git a/src/containers/ProfilePage/ProfilePage.css b/src/containers/ProfilePage/ProfilePage.css
new file mode 100644
index 00000000..864b65ec
--- /dev/null
+++ b/src/containers/ProfilePage/ProfilePage.css
@@ -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);
+ }
+}
diff --git a/src/containers/ProfilePage/ProfilePage.duck.js b/src/containers/ProfilePage/ProfilePage.duck.js
new file mode 100644
index 00000000..b2045057
--- /dev/null
+++ b/src/containers/ProfilePage/ProfilePage.duck.js
@@ -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)));
+};
diff --git a/src/containers/ProfilePage/ProfilePage.js b/src/containers/ProfilePage/ProfilePage.js
index 2bbdd0d2..e4228e92 100644
--- a/src/containers/ProfilePage/ProfilePage.js
+++ b/src/containers/ProfilePage/ProfilePage.js
@@ -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 ? (
+
+
+
+ ) : null;
+ const editLinkDesktop = isCurrentUser ? (
+
+
+
+ ) : null;
+
+ const asideContent = (
+
+
+
+ {displayName ? (
+
+ ) : null}
+
+ {editLinkMobile}
+ {editLinkDesktop}
+
+ );
+
+ const listingsContainerClasses = classNames(css.listingsContainer, {
+ [css.withBioMissingAbove]: !hasBio,
+ });
+
+ const mainContent = (
+
+
+
+
+ {hasBio ?
{bio}
: null}
+ {hasListings ? (
+
+
+
+
+
+ ) : null}
+
+ );
+
+ let content;
+
+ if (userShowError) {
+ content = (
+
+
+
+ );
+ } else if (userShowInProgress) {
+ content = (
+
+
+
+ );
+ } else {
+ content = mainContent;
+ }
+
+ const schemaTitle = 'Profile page';
return (
-
-
+
+
+
+
+
+ {asideContent}
+ {content}
+
+
+
+
);
};
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;
diff --git a/src/containers/ProfilePage/ProfilePage.test.js b/src/containers/ProfilePage/ProfilePage.test.js
index b6baa2cd..f3a35bdc 100644
--- a/src/containers/ProfilePage/ProfilePage.test.js
+++ b/src/containers/ProfilePage/ProfilePage.test.js
@@ -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(
);
expect(tree).toMatchSnapshot();
diff --git a/src/containers/ProfilePage/__snapshots__/ProfilePage.test.js.snap b/src/containers/ProfilePage/__snapshots__/ProfilePage.test.js.snap
index c8f7a0b0..4a769ef4 100644
--- a/src/containers/ProfilePage/__snapshots__/ProfilePage.test.js.snap
+++ b/src/containers/ProfilePage/__snapshots__/ProfilePage.test.js.snap
@@ -4,8 +4,79 @@ exports[`ProfilePage matches snapshot 1`] = `
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
diff --git a/src/containers/reducers.js b/src/containers/reducers.js
index 4a5e6506..4e8faa0a 100644
--- a/src/containers/reducers.js
+++ b/src/containers/reducers.js
@@ -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,
diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js
index 7104528a..40f5f498 100644
--- a/src/routeConfiguration.js
+++ b/src/routeConfiguration.js
@@ -133,9 +133,10 @@ const routeConfiguration = () => {
component: RedirectToLandingPage,
},
{
- path: '/u/:displayName',
+ path: '/u/:id',
name: 'ProfilePage',
component: props => ,
+ loadData: ProfilePage.loadData,
},
{
path: '/profile-settings',
diff --git a/src/translations/en.json b/src/translations/en.json
index 11755059..154369e9 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -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",