mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-28 03:43:28 +10:00
Merge branch 'master' into sync-avatars
This commit is contained in:
commit
bad45a9abe
105 changed files with 3906 additions and 1614 deletions
|
|
@ -34,7 +34,7 @@
|
|||
"redux-thunk": "^2.2.0",
|
||||
"sanitize.css": "^5.0.0",
|
||||
"sharetribe-scripts": "0.9.2",
|
||||
"sharetribe-sdk": "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#917869bbbfc791a38da193c956645779864c049d",
|
||||
"sharetribe-sdk": "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#0bc2a2e82be2c5eadbc22e511a750a2a91dc0fd5",
|
||||
"source-map-support": "^0.4.15",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ describe('Application', () => {
|
|||
'/signup': 'Sign up',
|
||||
'/password': 'Request new password',
|
||||
'/password/forgotten': 'Request new password',
|
||||
'/password/change': 'Type new password',
|
||||
'/this-url-should-not-be-found': 'Page not found',
|
||||
'/reset-password?t=token&e=email': 'Reset password',
|
||||
};
|
||||
forEach(urlTitles, (title, url) => {
|
||||
const context = {};
|
||||
|
|
@ -67,7 +67,7 @@ describe('Application', () => {
|
|||
'/l/new': defaultAuthPath,
|
||||
'/l/listing-title-slug/1234/new/description': defaultAuthPath,
|
||||
'/l/listing-title-slug/1234/checkout': defaultAuthPath,
|
||||
'/u/1234/edit': defaultAuthPath,
|
||||
'/profile-settings': defaultAuthPath,
|
||||
'/inbox': defaultAuthPath,
|
||||
'/inbox/orders': defaultAuthPath,
|
||||
'/inbox/sales': defaultAuthPath,
|
||||
|
|
@ -82,7 +82,7 @@ describe('Application', () => {
|
|||
'/account/contact-details': defaultAuthPath,
|
||||
'/account/payout-preferences': defaultAuthPath,
|
||||
'/account/security': defaultAuthPath,
|
||||
'/email_verification': loginPath,
|
||||
'/verify-email': loginPath,
|
||||
};
|
||||
forEach(urlRedirects, (redirectPath, url) => {
|
||||
const context = {};
|
||||
|
|
|
|||
|
|
@ -7,123 +7,42 @@
|
|||
* <input type="file" accept="images/*" onChange={handleChange} />
|
||||
* </AddImages>
|
||||
*/
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import React, { PropTypes } from 'react';
|
||||
import { SortableContainer } from 'react-sortable-hoc';
|
||||
import classNames from 'classnames';
|
||||
import { Promised, ResponsiveImage } from '../../components';
|
||||
import { uuid } from '../../util/propTypes';
|
||||
import { ImageFromFile, ResponsiveImage, SpinnerIcon } from '../../components';
|
||||
|
||||
import css from './AddImages.css';
|
||||
|
||||
const RemoveImageButton = props => {
|
||||
const { onClick } = props;
|
||||
return (
|
||||
<button className={css.removeImage} onClick={onClick}>
|
||||
<svg
|
||||
width="10px"
|
||||
height="10px"
|
||||
viewBox="0 0 10 10"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g strokeWidth="1" fillRule="evenodd">
|
||||
<g transform="translate(-821.000000, -311.000000)">
|
||||
<g transform="translate(809.000000, 299.000000)">
|
||||
<path
|
||||
d="M21.5833333,16.5833333 L17.4166667,16.5833333 L17.4166667,12.4170833 C17.4166667,12.1866667 17.2391667,12 17.00875,12 C16.77875,12 16.5920833,12.18625 16.5920833,12.41625 L16.5883333,16.5833333 L12.4166667,16.5833333 C12.18625,16.5833333 12,16.7695833 12,17 C12,17.23 12.18625,17.4166667 12.4166667,17.4166667 L16.5875,17.4166667 L16.5833333,21.5829167 C16.5829167,21.8129167 16.7691667,21.9995833 16.9991667,22 L16.9995833,22 C17.2295833,22 17.41625,21.81375 17.4166667,21.58375 L17.4166667,17.4166667 L21.5833333,17.4166667 C21.8133333,17.4166667 22,17.23 22,17 C22,16.7695833 21.8133333,16.5833333 21.5833333,16.5833333"
|
||||
transform="translate(17.000000, 17.000000) rotate(-45.000000) translate(-17.000000, -17.000000) "
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const { any, array, func, node, string, object } = PropTypes;
|
||||
|
||||
RemoveImageButton.propTypes = { onClick: func.isRequired };
|
||||
|
||||
// readImage returns a promise which is resolved
|
||||
// when FileReader has loaded given file as dataURL
|
||||
const readImage = file =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve(e.target.result);
|
||||
reader.onerror = e => {
|
||||
// eslint-disable-next-line
|
||||
console.error('Error (', e, `) happened while reading ${file.name}: ${e.target.result}`);
|
||||
reject(new Error(`Error reading ${file.name}: ${e.target.result}`));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Create sortable elments out of given thumbnail file
|
||||
class Thumbnail extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
promisedImage: readImage(this.props.file),
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, file, id, imageId, onRemoveImage } = this.props;
|
||||
|
||||
const handleRemoveClick = e => {
|
||||
e.preventDefault();
|
||||
onRemoveImage(id);
|
||||
};
|
||||
|
||||
// While image is uploading we show overlay on top of thumbnail
|
||||
const uploadingOverlay = !imageId
|
||||
? <div className={css.thumbnailLoading}><FormattedMessage id="AddImages.upload" /></div>
|
||||
: null;
|
||||
const removeButton = imageId ? <RemoveImageButton onClick={handleRemoveClick} /> : null;
|
||||
const classes = classNames(css.thumbnail, className);
|
||||
return (
|
||||
<Promised
|
||||
key={id}
|
||||
promise={this.state.promisedImage}
|
||||
renderFulfilled={dataURL => {
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className={css.aspectWrapper}>
|
||||
<img src={dataURL} alt={file.name} className={css.rootForImage} />
|
||||
</div>
|
||||
{removeButton}
|
||||
{uploadingOverlay}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderRejected={() => (
|
||||
<div className={css.thumbnail}><FormattedMessage id="AddImages.couldNotReadFile" /></div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Thumbnail.defaultProps = { className: null, imageId: null };
|
||||
|
||||
Thumbnail.propTypes = {
|
||||
className: string,
|
||||
file: any.isRequired,
|
||||
id: string.isRequired,
|
||||
imageId: uuid,
|
||||
onRemoveImage: func.isRequired,
|
||||
};
|
||||
import RemoveImageButton from './RemoveImageButton';
|
||||
|
||||
const ThumbnailWrapper = props => {
|
||||
const { className, image, savedImageAltText, onRemoveImage } = props;
|
||||
const handleRemoveClick = e => {
|
||||
e.stopPropagation();
|
||||
onRemoveImage(image.id);
|
||||
};
|
||||
|
||||
if (image.file) {
|
||||
return <Thumbnail className={className} onRemoveImage={onRemoveImage} {...image} />;
|
||||
// Add remove button only when the image has been uploaded and can be removed
|
||||
const removeButton = image.imageId ? <RemoveImageButton onClick={handleRemoveClick} /> : null;
|
||||
|
||||
// While image is uploading we show overlay on top of thumbnail
|
||||
const uploadingOverlay = !image.imageId
|
||||
? <div className={css.thumbnailLoading}><SpinnerIcon /></div>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ImageFromFile
|
||||
id={image.id}
|
||||
className={className}
|
||||
rootClassName={css.thumbnail}
|
||||
file={image.file}
|
||||
>
|
||||
{removeButton}
|
||||
{uploadingOverlay}
|
||||
</ImageFromFile>
|
||||
);
|
||||
} else {
|
||||
const handleRemoveClick = e => {
|
||||
e.preventDefault();
|
||||
onRemoveImage(image.id);
|
||||
};
|
||||
const classes = classNames(css.thumbnail, className);
|
||||
return (
|
||||
<div className={classes}>
|
||||
|
|
@ -149,6 +68,8 @@ const ThumbnailWrapper = props => {
|
|||
|
||||
ThumbnailWrapper.defaultProps = { className: null };
|
||||
|
||||
const { array, func, node, string, object } = PropTypes;
|
||||
|
||||
ThumbnailWrapper.propTypes = {
|
||||
className: string,
|
||||
image: object.isRequired,
|
||||
|
|
|
|||
42
src/components/AddImages/RemoveImageButton.js
Normal file
42
src/components/AddImages/RemoveImageButton.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import css from './AddImages.css';
|
||||
|
||||
const RemoveImageButton = props => {
|
||||
const { className, rootClassName, onClick } = props;
|
||||
const classes = classNames(rootClassName || css.removeImage, className);
|
||||
return (
|
||||
<button className={classes} onClick={onClick}>
|
||||
<svg
|
||||
width="10px"
|
||||
height="10px"
|
||||
viewBox="0 0 10 10"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g strokeWidth="1" fillRule="evenodd">
|
||||
<g transform="translate(-821.000000, -311.000000)">
|
||||
<g transform="translate(809.000000, 299.000000)">
|
||||
<path
|
||||
d="M21.5833333,16.5833333 L17.4166667,16.5833333 L17.4166667,12.4170833 C17.4166667,12.1866667 17.2391667,12 17.00875,12 C16.77875,12 16.5920833,12.18625 16.5920833,12.41625 L16.5883333,16.5833333 L12.4166667,16.5833333 C12.18625,16.5833333 12,16.7695833 12,17 C12,17.23 12.18625,17.4166667 12.4166667,17.4166667 L16.5875,17.4166667 L16.5833333,21.5829167 C16.5829167,21.8129167 16.7691667,21.9995833 16.9991667,22 L16.9995833,22 C17.2295833,22 17.41625,21.81375 17.4166667,21.58375 L17.4166667,17.4166667 L21.5833333,17.4166667 C21.8133333,17.4166667 22,17.23 22,17 C22,16.7695833 21.8133333,16.5833333 21.5833333,16.5833333"
|
||||
transform="translate(17.000000, 17.000000) rotate(-45.000000) translate(-17.000000, -17.000000) "
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
RemoveImageButton.defaultProps = { className: null, rootClassName: null };
|
||||
|
||||
const { func, string } = PropTypes;
|
||||
|
||||
RemoveImageButton.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
onClick: func.isRequired,
|
||||
};
|
||||
|
||||
export default RemoveImageButton;
|
||||
|
|
@ -69,6 +69,12 @@
|
|||
height: var(--avatarSizeLarge);
|
||||
}
|
||||
|
||||
.avatarImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.largeAvatar .initials {
|
||||
font-size: 30px;
|
||||
font-weight: var(--fontWeightSemiBold);
|
||||
|
|
|
|||
|
|
@ -2,20 +2,37 @@ import React, { PropTypes } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { ensureUser } from '../../util/data';
|
||||
import { ResponsiveImage } from '../../components/';
|
||||
|
||||
import css from './Avatar.css';
|
||||
|
||||
const Avatar = props => {
|
||||
const { rootClassName, className, user } = props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const { displayName, abbreviatedName } = ensureUser(user).attributes.profile;
|
||||
const placeHolderAvatar = (
|
||||
<div className={classes} title={displayName}>
|
||||
<span className={css.initials}>{abbreviatedName}</span>
|
||||
</div>
|
||||
);
|
||||
const avatarUser = ensureUser(user);
|
||||
const { displayName, abbreviatedName } = avatarUser.attributes.profile;
|
||||
|
||||
return placeHolderAvatar;
|
||||
// TODO this is a temporary avatar fix for currentUser's profile data.
|
||||
// Avatar images should be included to all user's attributes in the future.
|
||||
if (avatarUser.profileImage && avatarUser.profileImage.id) {
|
||||
return (
|
||||
<div className={classes} title={displayName}>
|
||||
<ResponsiveImage
|
||||
rootClassName={css.avatarImage}
|
||||
alt={displayName}
|
||||
image={avatarUser.profileImage}
|
||||
nameSet={[{ name: 'square-xlarge', size: '1x' }, { name: 'square-xlarge2x', size: '2x' }]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Placeholder avatar (initials)
|
||||
return (
|
||||
<div className={classes} title={displayName}>
|
||||
<span className={css.initials}>{abbreviatedName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const { string, oneOfType } = PropTypes;
|
||||
|
|
|
|||
|
|
@ -3,160 +3,219 @@ import { types } from '../../util/sdkLoader';
|
|||
import * as propTypes from '../../util/propTypes';
|
||||
import BookingBreakdown from './BookingBreakdown';
|
||||
|
||||
const { UUID, Money } = types;
|
||||
|
||||
const exampleBooking = attributes => {
|
||||
return {
|
||||
id: new UUID('example-booking'),
|
||||
type: 'booking',
|
||||
attributes,
|
||||
};
|
||||
};
|
||||
|
||||
const exampleTransaction = params => {
|
||||
const created = new Date(Date.UTC(2017, 1, 1));
|
||||
return {
|
||||
id: new UUID('example-transaction'),
|
||||
type: 'transaction',
|
||||
attributes: {
|
||||
createdAt: created,
|
||||
lastTransitionedAt: created,
|
||||
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
|
||||
state: propTypes.TX_STATE_PREAUTHORIZED,
|
||||
|
||||
// payinTotal, payoutTotal, and lineItems required in params
|
||||
...params,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const Checkout = {
|
||||
component: BookingBreakdown,
|
||||
props: {
|
||||
transactionState: propTypes.TX_STATE_PREAUTHORIZED,
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
|
||||
userRole: 'customer',
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
unitPrice: new types.Money(4500, 'USD'),
|
||||
lineTotal: new types.Money(9000, 'USD'),
|
||||
},
|
||||
],
|
||||
payinTotal: new types.Money(9000, 'USD'),
|
||||
transaction: exampleTransaction({
|
||||
payinTotal: new Money(9000, 'USD'),
|
||||
payoutTotal: new Money(9000, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
unitPrice: new Money(4500, 'USD'),
|
||||
lineTotal: new Money(9000, 'USD'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
booking: exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 16)),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomerOrder = {
|
||||
component: BookingBreakdown,
|
||||
props: {
|
||||
transactionState: propTypes.TX_STATE_PREAUTHORIZED,
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
|
||||
userRole: 'customer',
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
unitPrice: new types.Money(4500, 'USD'),
|
||||
lineTotal: new types.Money(9000, 'USD'),
|
||||
},
|
||||
],
|
||||
payinTotal: new types.Money(9000, 'USD'),
|
||||
transaction: exampleTransaction({
|
||||
payinTotal: new Money(9000, 'USD'),
|
||||
payoutTotal: new Money(9000, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
unitPrice: new Money(4500, 'USD'),
|
||||
lineTotal: new Money(9000, 'USD'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
booking: exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 16)),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ProviderSale = {
|
||||
component: BookingBreakdown,
|
||||
props: {
|
||||
transactionState: propTypes.TX_STATE_PREAUTHORIZED,
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
|
||||
userRole: 'provider',
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
unitPrice: new types.Money(4500, 'USD'),
|
||||
lineTotal: new types.Money(9000, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new types.Money(-2000, 'USD'),
|
||||
lineTotal: new types.Money(-2000, 'USD'),
|
||||
},
|
||||
],
|
||||
payoutTotal: new types.Money(7000, 'USD'),
|
||||
transaction: exampleTransaction({
|
||||
payinTotal: new Money(9000, 'USD'),
|
||||
payoutTotal: new Money(7000, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
unitPrice: new Money(4500, 'USD'),
|
||||
lineTotal: new Money(9000, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new Money(-2000, 'USD'),
|
||||
lineTotal: new Money(-2000, 'USD'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
booking: exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 16)),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ProviderSaleZeroCommission = {
|
||||
component: BookingBreakdown,
|
||||
props: {
|
||||
transactionState: propTypes.TX_STATE_PREAUTHORIZED,
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
|
||||
userRole: 'provider',
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
unitPrice: new types.Money(4500, 'USD'),
|
||||
lineTotal: new types.Money(9000, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new types.Money(0, 'USD'),
|
||||
lineTotal: new types.Money(0, 'USD'),
|
||||
},
|
||||
],
|
||||
payoutTotal: new types.Money(9000, 'USD'),
|
||||
transaction: exampleTransaction({
|
||||
payinTotal: new Money(9000, 'USD'),
|
||||
payoutTotal: new Money(9000, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
unitPrice: new Money(4500, 'USD'),
|
||||
lineTotal: new Money(9000, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new Money(0, 'USD'),
|
||||
lineTotal: new Money(0, 'USD'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
booking: exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 16)),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ProviderSaleSingleNight = {
|
||||
component: BookingBreakdown,
|
||||
props: {
|
||||
transactionState: propTypes.TX_STATE_PREAUTHORIZED,
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 15)),
|
||||
userRole: 'provider',
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(1),
|
||||
unitPrice: new types.Money(4500, 'USD'),
|
||||
lineTotal: new types.Money(4500, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new types.Money(-2000, 'USD'),
|
||||
lineTotal: new types.Money(-2000, 'USD'),
|
||||
},
|
||||
],
|
||||
payoutTotal: new types.Money(2500, 'USD'),
|
||||
transaction: exampleTransaction({
|
||||
payinTotal: new Money(4500, 'USD'),
|
||||
payoutTotal: new Money(2500, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(1),
|
||||
unitPrice: new Money(4500, 'USD'),
|
||||
lineTotal: new Money(4500, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new Money(-2000, 'USD'),
|
||||
lineTotal: new Money(-2000, 'USD'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
booking: exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 15)),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ProviderSaleRejected = {
|
||||
component: BookingBreakdown,
|
||||
props: {
|
||||
transactionState: propTypes.TX_STATE_REJECTED,
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 15)),
|
||||
userRole: 'provider',
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(1),
|
||||
unitPrice: new types.Money(4500, 'USD'),
|
||||
lineTotal: new types.Money(4500, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new types.Money(-2000, 'USD'),
|
||||
lineTotal: new types.Money(-2000, 'USD'),
|
||||
},
|
||||
],
|
||||
payoutTotal: new types.Money(2500, 'USD'),
|
||||
transaction: exampleTransaction({
|
||||
state: propTypes.TX_STATE_REJECTED,
|
||||
lastTransition: propTypes.TX_TRANSITION_REJECT,
|
||||
payinTotal: new Money(4500, 'USD'),
|
||||
payoutTotal: new Money(2500, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(1),
|
||||
unitPrice: new Money(4500, 'USD'),
|
||||
lineTotal: new Money(4500, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new Money(-2000, 'USD'),
|
||||
lineTotal: new Money(-2000, 'USD'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
booking: exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 15)),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ProviderSaleDelivered = {
|
||||
component: BookingBreakdown,
|
||||
props: {
|
||||
transactionState: propTypes.TX_STATE_DELIVERED,
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 15)),
|
||||
userRole: 'provider',
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(1),
|
||||
unitPrice: new types.Money(4500, 'USD'),
|
||||
lineTotal: new types.Money(4500, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new types.Money(-2000, 'USD'),
|
||||
lineTotal: new types.Money(-2000, 'USD'),
|
||||
},
|
||||
],
|
||||
payoutTotal: new types.Money(2500, 'USD'),
|
||||
transaction: exampleTransaction({
|
||||
state: propTypes.TX_STATE_DELIVERED,
|
||||
lastTransition: propTypes.TX_TRANSITION_MARK_DELIVERED,
|
||||
payinTotal: new Money(4500, 'USD'),
|
||||
payoutTotal: new Money(2500, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(1),
|
||||
unitPrice: new Money(4500, 'USD'),
|
||||
lineTotal: new Money(4500, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
unitPrice: new Money(-2000, 'USD'),
|
||||
lineTotal: new Money(-2000, 'USD'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
booking: exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 15)),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,38 +4,36 @@
|
|||
*/
|
||||
import React, { PropTypes } from 'react';
|
||||
import { FormattedMessage, FormattedHTMLMessage, intlShape, injectIntl } from 'react-intl';
|
||||
import Decimal from 'decimal.js';
|
||||
import classNames from 'classnames';
|
||||
import config from '../../config';
|
||||
import { convertMoneyToNumber, formatMoney } from '../../util/currency';
|
||||
import { types } from '../../util/sdkLoader';
|
||||
import { formatMoney } from '../../util/currency';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
|
||||
import css from './BookingBreakdown.css';
|
||||
|
||||
const { Money } = types;
|
||||
|
||||
// Validate the assumption that the commission exists and the amount
|
||||
// is zero or negative.
|
||||
const isValidCommission = commissionLineItem => {
|
||||
return commissionLineItem &&
|
||||
commissionLineItem.lineTotal instanceof Money &&
|
||||
commissionLineItem.lineTotal.amount <= 0;
|
||||
};
|
||||
|
||||
export const BookingBreakdownComponent = props => {
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
transactionState,
|
||||
bookingStart,
|
||||
bookingEnd,
|
||||
payinTotal,
|
||||
payoutTotal,
|
||||
lineItems,
|
||||
userRole,
|
||||
transaction,
|
||||
booking,
|
||||
intl,
|
||||
} = props;
|
||||
|
||||
const isProvider = userRole === 'provider';
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
|
||||
if (userRole === 'customer' && !payinTotal) {
|
||||
throw new Error('payinTotal is required for customer breakdown');
|
||||
}
|
||||
|
||||
if (userRole === 'provider' && !payoutTotal) {
|
||||
throw new Error('payoutTotal is required for provider breakdown');
|
||||
}
|
||||
|
||||
const dateFormatOptions = {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
|
|
@ -47,39 +45,49 @@ export const BookingBreakdownComponent = props => {
|
|||
values={{
|
||||
bookingStart: (
|
||||
<span className={css.nowrap}>
|
||||
{intl.formatDate(bookingStart, dateFormatOptions)}
|
||||
{intl.formatDate(booking.attributes.start, dateFormatOptions)}
|
||||
</span>
|
||||
),
|
||||
bookingEnd: (
|
||||
<span className={css.nowrap}>
|
||||
{intl.formatDate(bookingEnd, dateFormatOptions)}
|
||||
{intl.formatDate(booking.attributes.end, dateFormatOptions)}
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const nightPurchase = lineItems.find(item => item.code === 'line-item/night');
|
||||
const providerCommission = lineItems.find(item => item.code === 'line-item/provider-commission');
|
||||
const nightPurchase = transaction.attributes.lineItems.find(
|
||||
item => item.code === 'line-item/night'
|
||||
);
|
||||
const providerCommissionLineItem = transaction.attributes.lineItems.find(
|
||||
item => item.code === 'line-item/provider-commission'
|
||||
);
|
||||
|
||||
const nightCount = nightPurchase.quantity.toFixed();
|
||||
const nightCountMessage = (
|
||||
<FormattedHTMLMessage id="BookingBreakdown.nightCount" values={{ count: nightCount }} />
|
||||
);
|
||||
|
||||
const currencyConfig = config.currencyConfig;
|
||||
const formattedUnitPrice = formatMoney(intl, nightPurchase.unitPrice);
|
||||
|
||||
// If commission is passed it will be shown as a fee already reduces from the total price
|
||||
let subTotalInfo = null;
|
||||
let commissionInfo = null;
|
||||
|
||||
if (userRole === 'provider') {
|
||||
// TODO: Calculate subtotal from provider total and the commission
|
||||
const unitPriceAsNumber = convertMoneyToNumber(nightPurchase.unitPrice);
|
||||
const subTotal = new Decimal(nightCount).times(unitPriceAsNumber).toNumber();
|
||||
const formattedSubTotal = intl.formatNumber(subTotal, currencyConfig);
|
||||
if (isProvider) {
|
||||
if (!isValidCommission(providerCommissionLineItem)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('invalid commission line item:', providerCommissionLineItem);
|
||||
throw new Error('Commission should be present and the value should be zero or negative');
|
||||
}
|
||||
|
||||
// The total sum that the customer pays is the payinTotal. We use
|
||||
// this amount as the subtotal information for the provider
|
||||
// breakdown.
|
||||
//
|
||||
// NOTE: this might break if the API hides the payinTotal from the provider
|
||||
const formattedSubTotal = formatMoney(intl, transaction.attributes.payinTotal);
|
||||
subTotalInfo = (
|
||||
<div className={css.lineItem}>
|
||||
<span className={css.itemLabel}>
|
||||
|
|
@ -89,7 +97,7 @@ export const BookingBreakdownComponent = props => {
|
|||
</div>
|
||||
);
|
||||
|
||||
const commission = providerCommission.lineTotal;
|
||||
const commission = providerCommissionLineItem.lineTotal;
|
||||
const formattedCommission = commission ? formatMoney(intl, commission) : null;
|
||||
|
||||
commissionInfo = (
|
||||
|
|
@ -103,17 +111,19 @@ export const BookingBreakdownComponent = props => {
|
|||
}
|
||||
|
||||
let providerTotalMessageId = 'BookingBreakdown.providerTotalDefault';
|
||||
if (transactionState === propTypes.TX_STATE_DELIVERED) {
|
||||
if (transaction.attributes.state === propTypes.TX_STATE_DELIVERED) {
|
||||
providerTotalMessageId = 'BookingBreakdown.providerTotalDelivered';
|
||||
} else if (transactionState === propTypes.TX_STATE_REJECTED) {
|
||||
} else if (transaction.attributes.state === propTypes.TX_STATE_REJECTED) {
|
||||
providerTotalMessageId = 'BookingBreakdown.providerTotalRejected';
|
||||
}
|
||||
|
||||
const totalLabel = userRole === 'customer'
|
||||
? <FormattedMessage id="BookingBreakdown.total" />
|
||||
: <FormattedMessage id={providerTotalMessageId} />;
|
||||
const totalLabel = isProvider
|
||||
? <FormattedMessage id={providerTotalMessageId} />
|
||||
: <FormattedMessage id="BookingBreakdown.total" />;
|
||||
|
||||
const totalPrice = userRole === 'customer' ? payinTotal : payoutTotal;
|
||||
const totalPrice = isProvider
|
||||
? transaction.attributes.payoutTotal
|
||||
: transaction.attributes.payinTotal;
|
||||
const formattedTotalPrice = formatMoney(intl, totalPrice);
|
||||
|
||||
return (
|
||||
|
|
@ -145,33 +155,17 @@ export const BookingBreakdownComponent = props => {
|
|||
);
|
||||
};
|
||||
|
||||
BookingBreakdownComponent.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
payinTotal: null,
|
||||
payoutTotal: null,
|
||||
};
|
||||
BookingBreakdownComponent.defaultProps = { rootClassName: null, className: null };
|
||||
|
||||
const { arrayOf, instanceOf, oneOf, shape, string } = PropTypes;
|
||||
|
||||
const lineItem = shape({
|
||||
code: string.isRequired,
|
||||
quantity: instanceOf(Decimal),
|
||||
unitPrice: propTypes.money,
|
||||
lineTotal: propTypes.money,
|
||||
});
|
||||
const { oneOf, string } = PropTypes;
|
||||
|
||||
BookingBreakdownComponent.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
|
||||
transactionState: oneOf(propTypes.TX_STATES).isRequired,
|
||||
bookingStart: instanceOf(Date).isRequired,
|
||||
bookingEnd: instanceOf(Date).isRequired,
|
||||
lineItems: arrayOf(lineItem).isRequired,
|
||||
userRole: oneOf(['customer', 'provider']).isRequired,
|
||||
payinTotal: propTypes.money, // required if userRole === customer
|
||||
payoutTotal: propTypes.money, // required if userRole === provider
|
||||
transaction: propTypes.transaction.isRequired,
|
||||
booking: propTypes.booking.isRequired,
|
||||
|
||||
// from injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
|
|
|
|||
|
|
@ -6,23 +6,54 @@ import { types } from '../../util/sdkLoader';
|
|||
import * as propTypes from '../../util/propTypes';
|
||||
import { BookingBreakdownComponent } from './BookingBreakdown';
|
||||
|
||||
const { UUID, Money } = types;
|
||||
|
||||
const exampleBooking = attributes => {
|
||||
return {
|
||||
id: new UUID('example-booking'),
|
||||
type: 'booking',
|
||||
attributes,
|
||||
};
|
||||
};
|
||||
|
||||
const exampleTransaction = params => {
|
||||
const created = new Date(Date.UTC(2017, 1, 1));
|
||||
return {
|
||||
id: new UUID('example-transaction'),
|
||||
type: 'transaction',
|
||||
attributes: {
|
||||
createdAt: created,
|
||||
lastTransitionedAt: created,
|
||||
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
|
||||
state: propTypes.TX_STATE_PREAUTHORIZED,
|
||||
|
||||
// payinTotal, payoutTotal, and lineItems required in params
|
||||
...params,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('BookingBreakdown', () => {
|
||||
it('pretransaction data matches snapshot', () => {
|
||||
const tree = renderDeep(
|
||||
<BookingBreakdownComponent
|
||||
transactionState={propTypes.TX_STATE_PREAUTHORIZED}
|
||||
bookingStart={new Date(Date.UTC(2017, 3, 14))}
|
||||
bookingEnd={new Date(Date.UTC(2017, 3, 16))}
|
||||
payinTotal={new types.Money(2000, 'USD')}
|
||||
userRole="customer"
|
||||
lineItems={[
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
lineTotal: new types.Money(2000, 'USD'),
|
||||
unitPrice: new types.Money(1000, 'USD'),
|
||||
},
|
||||
]}
|
||||
transaction={exampleTransaction({
|
||||
payinTotal: new Money(2000, 'USD'),
|
||||
payoutTotal: new Money(2000, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
lineTotal: new Money(2000, 'USD'),
|
||||
unitPrice: new Money(1000, 'USD'),
|
||||
},
|
||||
],
|
||||
})}
|
||||
booking={exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 16)),
|
||||
})}
|
||||
intl={fakeIntl}
|
||||
/>
|
||||
);
|
||||
|
|
@ -32,19 +63,23 @@ describe('BookingBreakdown', () => {
|
|||
it('customer transaction data matches snapshot', () => {
|
||||
const tree = renderDeep(
|
||||
<BookingBreakdownComponent
|
||||
transactionState={propTypes.TX_STATE_PREAUTHORIZED}
|
||||
bookingStart={new Date(Date.UTC(2017, 3, 14))}
|
||||
bookingEnd={new Date(Date.UTC(2017, 3, 16))}
|
||||
userRole="customer"
|
||||
payinTotal={new types.Money(2000, 'USD')}
|
||||
lineItems={[
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
lineTotal: new types.Money(2000, 'USD'),
|
||||
unitPrice: new types.Money(1000, 'USD'),
|
||||
},
|
||||
]}
|
||||
transaction={exampleTransaction({
|
||||
payinTotal: new Money(2000, 'USD'),
|
||||
payoutTotal: new Money(2000, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
lineTotal: new Money(2000, 'USD'),
|
||||
unitPrice: new Money(1000, 'USD'),
|
||||
},
|
||||
],
|
||||
})}
|
||||
booking={exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 16)),
|
||||
})}
|
||||
intl={fakeIntl}
|
||||
/>
|
||||
);
|
||||
|
|
@ -54,25 +89,28 @@ describe('BookingBreakdown', () => {
|
|||
it('provider transaction data matches snapshot', () => {
|
||||
const tree = renderDeep(
|
||||
<BookingBreakdownComponent
|
||||
transactionState={propTypes.TX_STATE_PREAUTHORIZED}
|
||||
bookingStart={new Date(Date.UTC(2017, 3, 14))}
|
||||
bookingEnd={new Date(Date.UTC(2017, 3, 16))}
|
||||
commission={new types.Money(200, 'USD')}
|
||||
payoutTotal={new types.Money(1800, 'USD')}
|
||||
userRole="provider"
|
||||
lineItems={[
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
lineTotal: new types.Money(2000, 'USD'),
|
||||
unitPrice: new types.Money(1000, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
lineTotal: new types.Money(-200, 'USD'),
|
||||
unitPrice: new types.Money(-200, 'USD'),
|
||||
},
|
||||
]}
|
||||
transaction={exampleTransaction({
|
||||
payinTotal: new Money(2000, 'USD'),
|
||||
payoutTotal: new Money(1800, 'USD'),
|
||||
lineItems: [
|
||||
{
|
||||
code: 'line-item/night',
|
||||
quantity: new Decimal(2),
|
||||
lineTotal: new Money(2000, 'USD'),
|
||||
unitPrice: new Money(1000, 'USD'),
|
||||
},
|
||||
{
|
||||
code: 'line-item/provider-commission',
|
||||
lineTotal: new Money(-200, 'USD'),
|
||||
unitPrice: new Money(-200, 'USD'),
|
||||
},
|
||||
],
|
||||
})}
|
||||
booking={exampleBooking({
|
||||
start: new Date(Date.UTC(2017, 3, 14)),
|
||||
end: new Date(Date.UTC(2017, 3, 16)),
|
||||
})}
|
||||
intl={fakeIntl}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
/**
|
||||
* DateRangeInput wraps DateRangePicker from React-dates and gives a list of all default props we use.
|
||||
* Styles for DateRangePicker can be found from 'public/reactDates.css'.
|
||||
*
|
||||
* N.B. *isOutsideRange* in defaultProps is defining what dates are available to booking.
|
||||
*/
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { intlShape, injectIntl } from 'react-intl';
|
||||
import { DateRangePicker, isInclusivelyAfterDay } from 'react-dates';
|
||||
import { DateRangePicker, isInclusivelyAfterDay, isInclusivelyBeforeDay } from 'react-dates';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
|
||||
|
|
@ -71,7 +73,15 @@ const defaultProps = {
|
|||
minimumNights: 1,
|
||||
enableOutsideDays: false,
|
||||
isDayBlocked: () => false,
|
||||
isOutsideRange: day => !isInclusivelyAfterDay(day, moment()),
|
||||
|
||||
// Stripe holds funds in a reserve for up to 90 days from charge creation.
|
||||
// outside range -><- today ... today+89 days -><- outside range
|
||||
isOutsideRange: day => {
|
||||
const daysCountAvailableToBook = 90;
|
||||
const endOfRange = daysCountAvailableToBook - 1;
|
||||
return !isInclusivelyAfterDay(day, moment()) ||
|
||||
!isInclusivelyBeforeDay(day, moment().add(endOfRange, 'days'));
|
||||
},
|
||||
isDayHighlighted: () => {},
|
||||
|
||||
// Internationalization props
|
||||
|
|
|
|||
13
src/components/IconEmailAttention/IconEmailAttention.css
Normal file
13
src/components/IconEmailAttention/IconEmailAttention.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.marketplaceStroke {
|
||||
stroke: var(--marketplaceColor);
|
||||
}
|
||||
|
||||
.attentionStroke {
|
||||
stroke: var(--attentionColor);
|
||||
}
|
||||
41
src/components/IconEmailAttention/IconEmailAttention.js
Normal file
41
src/components/IconEmailAttention/IconEmailAttention.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import css from './IconEmailAttention.css';
|
||||
|
||||
const IconEmailAttention = props => {
|
||||
const { rootClassName, className } = props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
return (
|
||||
<svg
|
||||
className={classes}
|
||||
width="52"
|
||||
height="45"
|
||||
viewBox="0 0 52 45"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path
|
||||
className={css.attentionStroke}
|
||||
d="M50 30.5C50 37.406 44.406 43 37.5 43S25 37.406 25 30.5C25 23.596 30.594 18 37.5 18S50 23.596 50 30.5z"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<path className={css.attentionStroke} strokeWidth="2.5" d="M44 28l-8 8-4-4" />
|
||||
<path
|
||||
className={css.marketplaceStroke}
|
||||
d="M20.43 32H5.07C3.377 32 2 30.558 2 28.786V5.214C2 3.438 3.376 2 5.07 2h36.86C43.623 2 45 3.438 45 5.214v9.643"
|
||||
strokeWidth="2.75"
|
||||
/>
|
||||
<path className={css.marketplaceStroke} strokeWidth="2.75" d="M43 4.026L23.015 17 3 4" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
IconEmailAttention.defaultProps = { rootClassName: null, className: null };
|
||||
|
||||
const { string } = PropTypes;
|
||||
|
||||
IconEmailAttention.propTypes = { rootClassName: string, className: string };
|
||||
|
||||
export default IconEmailAttention;
|
||||
5
src/components/IconEmailSent/IconEmailSent.css
Normal file
5
src/components/IconEmailSent/IconEmailSent.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
stroke: var(--marketplaceColor);
|
||||
}
|
||||
45
src/components/IconEmailSent/IconEmailSent.js
Normal file
45
src/components/IconEmailSent/IconEmailSent.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import css from './IconEmailSent.css';
|
||||
|
||||
const IconEmailSent = props => {
|
||||
const { rootClassName, className } = props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
return (
|
||||
<svg
|
||||
className={classes}
|
||||
width="70"
|
||||
height="33"
|
||||
viewBox="0 0 70 33"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M59.592 26.65c-.958 2.4-3.67 4.35-6.056 4.35H18.93c-2.387 0-3.552-1.95-2.595-4.35l8.075-20.3C25.364 3.95 28.078 2 30.466 2H65.07c2.39 0 3.55 1.95 2.596 4.35l-8.074 20.3z"
|
||||
/>
|
||||
<path d="M62 8L41.345 19 30 8M22 26l10-7M54 26l-4.5-5.5M17 5H2M6.528 25H2M11.513 15.5H2" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
IconEmailSent.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
};
|
||||
|
||||
const { string } = PropTypes;
|
||||
|
||||
IconEmailSent.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
};
|
||||
|
||||
export default IconEmailSent;
|
||||
17
src/components/IconEmailSuccess/IconEmailSuccess.css
Normal file
17
src/components/IconEmailSuccess/IconEmailSuccess.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.marketplaceStroke {
|
||||
stroke: var(--marketplaceColor);
|
||||
}
|
||||
|
||||
.successFill {
|
||||
fill: var(--successColor);
|
||||
}
|
||||
|
||||
.checkStroke {
|
||||
stroke: var(--matterColorLight);
|
||||
}
|
||||
51
src/components/IconEmailSuccess/IconEmailSuccess.js
Normal file
51
src/components/IconEmailSuccess/IconEmailSuccess.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import css from './IconEmailSuccess.css';
|
||||
|
||||
const IconEmailSuccess = props => {
|
||||
const { rootClassName, className } = props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
return (
|
||||
<svg
|
||||
className={classes}
|
||||
width="51"
|
||||
height="44"
|
||||
viewBox="0 0 51 44"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g transform="translate(2 2)" fill="none" fillRule="evenodd">
|
||||
<circle className={css.successFill} cx="35.5" cy="28.5" r="13.5" />
|
||||
<path
|
||||
className={css.checkStroke}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M42 26l-8 8-4-4"
|
||||
/>
|
||||
<path
|
||||
d="M18.43 30H3.07C1.377 30 0 28.558 0 26.786V3.214C0 1.438 1.376 0 3.07 0h36.86C41.623 0 43 1.438 43 3.214v9.643"
|
||||
className={css.marketplaceStroke}
|
||||
strokeWidth="2.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
className={css.marketplaceStroke}
|
||||
strokeWidth="2.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M41 2.026L21.015 15 1 2"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
IconEmailSuccess.defaultProps = { rootClassName: null, className: null };
|
||||
|
||||
const { string } = PropTypes;
|
||||
|
||||
IconEmailSuccess.propTypes = { rootClassName: string, className: string };
|
||||
|
||||
export default IconEmailSuccess;
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
:root {
|
||||
--verticalPadding: 60px;
|
||||
--verticalPadding: 100px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--verticalPadding) 0;
|
||||
padding: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
padding: var(--verticalPadding) 10vw;;
|
||||
|
|
@ -61,6 +61,19 @@
|
|||
cursor: pointer;
|
||||
|
||||
background-size: 13px auto;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
background-size: 20px auto;
|
||||
}
|
||||
|
||||
@media (--viewportLarge) {
|
||||
opacity: 0.5;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prev {
|
||||
|
|
@ -70,6 +83,8 @@
|
|||
|
||||
@media (--viewportMedium) {
|
||||
background-position: center left 5vw;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg width="20" height="31" viewBox="0 0 20 31" xmlns="http://www.w3.org/2000/svg"><path d="M18.6667957 0c.37866928 0 .75333854.1550012 1.0186737.4585452.47466996.5425042.4040028 1.3575521-.1573344 1.8199723L3.3986902 15.5001192 19.528135 28.72172085c.5613372.46242022.63200436 1.27746815.1573344 1.821264-.47733663.54508752-1.31734243.61096303-1.880013.15241784L.4720033 16.4869601C.1720012 16.2402499 0 15.8798721 0 15.5001192c0-.3797529.1720012-.7401307.4720033-.9868409L17.8054564.3048357C18.0561248.1007508 18.3627936 0 18.6667957 0" fill="#FFF" fill-rule="evenodd"/></svg>');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +95,7 @@
|
|||
|
||||
@media (--viewportMedium) {
|
||||
background-position: center right 5vw;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg width="20" height="31" viewBox="0 0 20 31" xmlns="http://www.w3.org/2000/svg"><path d="M1.3332 31c-.37866 0-.75333-.1550012-1.01867-.4585452-.47467-.5425042-.404-1.3575521.15734-1.8199723l16.12944-13.2216017L.47187 2.27827915C-.08947 1.81585893-.16014 1.000811.31453.45701515.79187-.08807237 1.63187-.15394788 2.19454.3045973L19.528 14.5130399c.3.2467102.472.607088.472.9868409 0 .3797529-.172.7401307-.472.9868409L2.19454 30.6951643C1.94388 30.8992492 1.63721 31 1.3332 31" fill="#FFF" fill-rule="evenodd"/></svg>');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
31
src/components/ImageFromFile/ImageFromFile.css
Normal file
31
src/components/ImageFromFile/ImageFromFile.css
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--matterColorNegative); /* Loading BG color */
|
||||
}
|
||||
|
||||
.threeToTwoWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.aspectWrapper {
|
||||
padding-bottom: calc(100% * (2 / 3));
|
||||
}
|
||||
|
||||
.rootForImage {
|
||||
/* Layout - image will take space defined by aspect ratio wrapper */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: var(--borderRadius);
|
||||
}
|
||||
77
src/components/ImageFromFile/ImageFromFile.js
Normal file
77
src/components/ImageFromFile/ImageFromFile.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { Promised } from '../../components';
|
||||
|
||||
import css from './ImageFromFile.css';
|
||||
|
||||
// readImage returns a promise which is resolved
|
||||
// when FileReader has loaded given file as dataURL
|
||||
const readImage = file =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve(e.target.result);
|
||||
reader.onerror = e => {
|
||||
// eslint-disable-next-line
|
||||
console.error('Error (', e, `) happened while reading ${file.name}: ${e.target.result}`);
|
||||
reject(new Error(`Error reading ${file.name}: ${e.target.result}`));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Create elements out of given thumbnail file
|
||||
class ImageFromFile extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
promisedImage: readImage(this.props.file),
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, rootClassName, aspectRatioClassName, file, id, children } = this.props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const aspectRatioClasses = aspectRatioClassName || css.aspectWrapper;
|
||||
return (
|
||||
<Promised
|
||||
key={id}
|
||||
promise={this.state.promisedImage}
|
||||
renderFulfilled={dataURL => {
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className={css.threeToTwoWrapper}>
|
||||
<div className={aspectRatioClasses}>
|
||||
<img src={dataURL} alt={file.name} className={css.rootForImage} />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderRejected={() => (
|
||||
<div className={classes}><FormattedMessage id="ImageFromFile.couldNotReadFile" /></div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImageFromFile.defaultProps = {
|
||||
className: null,
|
||||
children: null,
|
||||
rootClassName: null,
|
||||
aspectRatioClassName: null,
|
||||
};
|
||||
|
||||
const { any, node, string } = PropTypes;
|
||||
|
||||
ImageFromFile.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
aspectRatioClassName: string,
|
||||
file: any.isRequired,
|
||||
id: string.isRequired,
|
||||
children: node,
|
||||
};
|
||||
|
||||
export default ImageFromFile;
|
||||
57
src/components/KeysIcon/KeysIcon.js
Normal file
57
src/components/KeysIcon/KeysIcon.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
|
||||
const KeysIcon = props => {
|
||||
const { className } = props;
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="50px"
|
||||
height="57px"
|
||||
viewBox="0 0 50 57"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
stroke="none"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<g transform="translate(-538.000000, -240.000000)" stroke="#C0392B">
|
||||
<g transform="translate(540.000000, 242.000000)">
|
||||
<g>
|
||||
<path
|
||||
d="M25,19.65 C25,12.6626 19.4022727,7 12.5,7 C5.59772727,7 0,12.6626 0,19.65 C0,25.4391 3.84772727,30.3082 9.09090909,31.8101 L9.09090909,34.6 L6.81818182,36.8977 L9.09090909,39.2 L9.09090909,41.5 L6.81818182,43.8 L9.09090909,46.1 L9.09090909,49.55 L12.5,53 L15.9090909,49.55 L15.9090909,31.8101 C21.1522727,30.3082 25,25.4391 25,19.65 L25,19.65 Z"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<path
|
||||
d="M16,15.5 C16,17.432 14.432,19 12.5,19 C10.568,19 9,17.432 9,15.5 C9,13.568 10.568,12 12.5,12 C14.432,12 16,13.568 16,15.5 L16,15.5 Z"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M16,33.460821 C19.8584615,35.6660894 24.6353846,35.6843714 28.5192308,33.5362343 L31,35.7163649 L31,39.1442433 L34.4615385,39.1442433 L35.6153846,40.3440007 L35.6153846,43.7147478 L38.6546154,43.7147478 L41.1053846,46 L46,46 L46,41.15298 L33.4138462,28.6892143 C36.1138462,23.9061813 35.4192308,17.7474266 31.3138462,13.6819628 C28.42,10.8162565 24.4784615,9.62335484 20.71,10.1032578"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<path
|
||||
d="M13,15 L13,4.61538462 C13,2.06769231 15.016,0 17.5,0 C19.984,0 22,2.06769231 22,4.61538462 L22,9.81230769"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
KeysIcon.defaultProps = { className: null };
|
||||
|
||||
const { string } = PropTypes;
|
||||
|
||||
KeysIcon.propTypes = {
|
||||
className: string,
|
||||
};
|
||||
|
||||
export default KeysIcon;
|
||||
57
src/components/KeysIconSuccess/KeysIconSuccess.js
Normal file
57
src/components/KeysIconSuccess/KeysIconSuccess.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
|
||||
const KeysIconSuccess = props => {
|
||||
const { className } = props;
|
||||
return (
|
||||
<svg className={className} width="52" height="60" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(2 2)" fill="none" fillRule="evenodd">
|
||||
<path
|
||||
d="M25 19.6C25 12.6 19.4 7 12.5 7 5.5 7 0 12.7 0 19.6c0 5.8 3.8 10.7 9 12.2v2.8L7 37 9 39v2.3L7 43.8 9 46v3.5l3.5 3.5 3.4-3.5V31.8c5.2-1.5 9-6.4 9-12.2z"
|
||||
stroke="#C0392B"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16 15.5c0 2-1.6 3.5-3.5 3.5-2 0-3.5-1.6-3.5-3.5 0-2 1.6-3.5 3.5-3.5 2 0 3.5 1.6 3.5 3.5z"
|
||||
stroke="#C0392B"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16 33.5c4 2.2 8.6 2.2 12.5 0l2.5 2.2V39h3.5l1 1.3v3.4h3.2L41 46h5v-4.8L33.4 28.7c2.7-4.8 2-11-2-15-3-3-7-4-10.7-3.6"
|
||||
stroke="#C0392B"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 15V4.6C13 2 15 0 17.5 0S22 2 22 4.6v5.2"
|
||||
stroke="#C0392B"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle stroke="#FFF" strokeWidth="2" fill="#2ECC71" cx="35.5" cy="43.5" r="13.5" />
|
||||
<path
|
||||
stroke="#FFF"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M42 41l-8 8-4-4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
KeysIconSuccess.defaultProps = { className: null };
|
||||
|
||||
const { string } = PropTypes;
|
||||
|
||||
KeysIconSuccess.propTypes = {
|
||||
className: string,
|
||||
};
|
||||
|
||||
export default KeysIconSuccess;
|
||||
|
|
@ -16,19 +16,18 @@
|
|||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: var(--boxShadowListingCard);
|
||||
}
|
||||
}
|
||||
|
||||
/* Firefox doesn't support image aspect ratio inside flexbox */
|
||||
.aspectWrapper {
|
||||
padding-bottom: 66.6667%; /* 3:2 Aspect Ratio */
|
||||
background: var(--matterColorNegative); /* Loading BG color */
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: var(--boxShadowListingCard);
|
||||
transition: var(--transitionStyleButton);
|
||||
}
|
||||
}
|
||||
|
||||
.rootForImage {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import React from 'react';
|
|||
import ListingCard from './ListingCard';
|
||||
import { createUser, createListing, fakeIntl } from '../../util/test-data';
|
||||
|
||||
const author = createUser('user1');
|
||||
const listing = { ...createListing('listing1'), author };
|
||||
const listing = createListing('listing1', {}, { author: createUser('user1') });
|
||||
|
||||
const ListingCardWrapper = props => (
|
||||
<div style={{ maxWidth: '400px' }}>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import { ListingCardComponent } from './ListingCard';
|
|||
|
||||
describe('ListingCard', () => {
|
||||
it('matches snapshot', () => {
|
||||
const author = createUser('user1');
|
||||
const listing = { ...createListing('listing1'), author };
|
||||
const listing = createListing('listing1', {}, { author: createUser('user1') });
|
||||
const tree = renderShallow(<ListingCardComponent listing={listing} intl={fakeIntl} />);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,30 +66,20 @@
|
|||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover .menuOverlay {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:hover .menuOverlayContent {
|
||||
opacity: 1.0;
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: var(--boxShadowListingCard);
|
||||
}
|
||||
}
|
||||
|
||||
.menuOverlayOpen {
|
||||
& .menuOverlay {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
& .menuOverlayContent {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Firefox doesn't support image aspect ratio inside flexbox */
|
||||
.aspectWrapper {
|
||||
padding-bottom: 66.6667%; /* 3:2 Aspect Ratio */
|
||||
background: var(--matterColorNegative); /* Loading BG color */
|
||||
margin-bottom: 1px; /* Fix 1px bug */
|
||||
}
|
||||
|
||||
.rootForImage {
|
||||
|
|
@ -133,19 +123,24 @@
|
|||
}
|
||||
|
||||
.menuLabel {
|
||||
padding: 8px 8px 0 8px;
|
||||
padding: 0px 9px 0 8px;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
padding: 2px 10px 8px 10px;
|
||||
padding: 2px 10px 0 10px;
|
||||
color: var(--matterColorLight);
|
||||
border-radius: 2px 2px 0 0;
|
||||
border-radius: 4px;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.listingMenuIsOpen {
|
||||
& .iconWrapper {
|
||||
background-color: var(--matterColorLight);
|
||||
color: var(--matterColor);
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
color: var(--matterColorLight);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,25 +153,28 @@
|
|||
right: 0;
|
||||
z-index: var(--zIndexPopup);
|
||||
|
||||
background-color: var(--matterColorLight);
|
||||
border-radius: 2px 0 2px 2px;
|
||||
background-color: var(--matterColor);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--boxShadowPopup);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
color: var(--matterColor);
|
||||
@apply --marketplaceH5FontStyles;
|
||||
color: var(--matterColorLight);
|
||||
font-weight: var(--fontWeightMedium);
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
min-width: 175px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Remove default margins from font */
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
color: var(--failColor);
|
||||
text-decoration: none;
|
||||
background-color: var(--failColor);
|
||||
}
|
||||
|
||||
@media (--viewportMedium) {
|
||||
|
|
@ -278,12 +276,14 @@
|
|||
}
|
||||
|
||||
.loadingOverlayWrapper,
|
||||
.errorOverlayWrapper,
|
||||
.closedOverlayWrapper {
|
||||
/* Positioning */
|
||||
@apply --coverEverything;
|
||||
}
|
||||
|
||||
.loadingOverlay,
|
||||
.errorOverlay,
|
||||
.closedOverlay {
|
||||
/* Positioning */
|
||||
@apply --coverEverything;
|
||||
|
|
@ -294,8 +294,9 @@
|
|||
}
|
||||
|
||||
.loadingOverlayContent,
|
||||
.errorOverlayContent,
|
||||
.closedOverlayContent {
|
||||
@apply --marketplaceTinyFontStyles;
|
||||
@apply --marketplaceH4FontStyles;
|
||||
color: var(--matterColor);
|
||||
|
||||
/* Positioning */
|
||||
|
|
@ -318,15 +319,29 @@
|
|||
}
|
||||
}
|
||||
|
||||
.errorOverlayContent {
|
||||
color: var(--failColor);
|
||||
justify-content: flex-start;
|
||||
margin-top: 90px;
|
||||
}
|
||||
|
||||
.closedMessage {
|
||||
max-width: 180px;
|
||||
max-width: 220px;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.openListingButton {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
width: 114px;
|
||||
height: 41px;
|
||||
padding: 8px 0;
|
||||
padding: 6px 0 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cardIsOpen {
|
||||
display: block;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
/* eslint-disable no-console */
|
||||
import React from 'react';
|
||||
import ManageListingCard from './ManageListingCard';
|
||||
import { createOwnListing, fakeIntl } from '../../util/test-data';
|
||||
import { createListing, fakeIntl } from '../../util/test-data';
|
||||
|
||||
const noop = () => null;
|
||||
const listing = { ...createOwnListing('listing1') };
|
||||
|
||||
const ManageListingCardWrapper = props => (
|
||||
<div style={{ maxWidth: '400px' }}>
|
||||
|
|
@ -15,8 +14,10 @@ const ManageListingCardWrapper = props => (
|
|||
export const ManageListingCardWrapped = {
|
||||
component: ManageListingCardWrapper,
|
||||
props: {
|
||||
hasClosingError: false,
|
||||
hasOpeningError: false,
|
||||
intl: fakeIntl,
|
||||
listing,
|
||||
listing: createListing('listing1'),
|
||||
isMenuOpen: false,
|
||||
onCloseListing: noop,
|
||||
onOpenListing: noop,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import MenuIcon from './MenuIcon';
|
|||
import css from './ManageListingCard.css';
|
||||
|
||||
// Menu content needs the same padding
|
||||
const MENU_CONTENT_OFFSET = 8;
|
||||
const MENU_CONTENT_OFFSET = -12;
|
||||
|
||||
const priceData = (price, intl) => {
|
||||
if (price && price.currency === config.currency) {
|
||||
|
|
@ -61,6 +61,8 @@ export const ManageListingCardComponent = props => {
|
|||
className,
|
||||
rootClassName,
|
||||
flattenedRoutes,
|
||||
hasClosingError,
|
||||
hasOpeningError,
|
||||
history,
|
||||
intl,
|
||||
isMenuOpen,
|
||||
|
|
@ -117,8 +119,25 @@ export const ManageListingCardComponent = props => {
|
|||
</div>
|
||||
</div>;
|
||||
|
||||
const errorOverlay = hasOpeningError || hasClosingError
|
||||
? <div
|
||||
className={css.errorOverlayWrapper}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className={css.errorOverlay} />
|
||||
<div className={css.errorOverlayContent}>
|
||||
<div className={css.closedMessage}>
|
||||
<FormattedMessage id="ManageListingCard.actionFailed" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
: null;
|
||||
|
||||
const thisInProgress = actionsInProgressListingId && actionsInProgressListingId.uuid === id;
|
||||
const loadingOrClosedOverlay = thisInProgress
|
||||
const loadingOrErrorOverlay = thisInProgress
|
||||
? <div
|
||||
className={css.loadingOverlayWrapper}
|
||||
onClick={event => {
|
||||
|
|
@ -131,7 +150,7 @@ export const ManageListingCardComponent = props => {
|
|||
<SpinnerIcon />
|
||||
</div>
|
||||
</div>
|
||||
: closedOverlay;
|
||||
: errorOverlay;
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
|
||||
return (
|
||||
|
|
@ -158,6 +177,7 @@ export const ManageListingCardComponent = props => {
|
|||
<div className={css.menubarGradient} />
|
||||
<div className={css.menubar}>
|
||||
<Menu
|
||||
className={classNames(css.menu, { [css.cardIsOpen]: open })}
|
||||
contentPlacementOffset={MENU_CONTENT_OFFSET}
|
||||
contentPosition="left"
|
||||
useArrow={false}
|
||||
|
|
@ -192,6 +212,8 @@ export const ManageListingCardComponent = props => {
|
|||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
{closedOverlay}
|
||||
{loadingOrErrorOverlay}
|
||||
</div>
|
||||
<div className={css.info}>
|
||||
<div className={css.price}>
|
||||
|
|
@ -218,7 +240,6 @@ export const ManageListingCardComponent = props => {
|
|||
<FormattedMessage id="ManageListingCard.edit" />
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
{loadingOrClosedOverlay}
|
||||
</NamedLink>
|
||||
);
|
||||
};
|
||||
|
|
@ -234,6 +255,8 @@ const { arrayOf, bool, func, shape, string } = PropTypes;
|
|||
ManageListingCardComponent.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
hasClosingError: bool.isRequired,
|
||||
hasOpeningError: bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
listing: propTypes.listing.isRequired,
|
||||
isMenuOpen: bool.isRequired,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
import React from 'react';
|
||||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { createUser, createOwnListing, fakeIntl } from '../../util/test-data';
|
||||
import { createUser, createListing, fakeIntl } from '../../util/test-data';
|
||||
import { ManageListingCardComponent } from './ManageListingCard';
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
describe('ManageListingCard', () => {
|
||||
it('matches snapshot', () => {
|
||||
const listing = { ...createOwnListing('listing1') };
|
||||
const tree = renderShallow(
|
||||
<ManageListingCardComponent
|
||||
flattenedRoutes={[]}
|
||||
history={{ push: noop }}
|
||||
listing={listing}
|
||||
listing={createListing('listing1')}
|
||||
intl={fakeIntl}
|
||||
isMenuOpen={false}
|
||||
onCloseListing={noop}
|
||||
onOpenListing={noop}
|
||||
onToggleMenu={noop}
|
||||
hasClosingError={false}
|
||||
hasOpeningError={false}
|
||||
/>
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ exports[`ManageListingCard matches snapshot 1`] = `
|
|||
<div />
|
||||
<div>
|
||||
<Menu
|
||||
className={null}
|
||||
contentPlacementOffset={8}
|
||||
className="undefined"
|
||||
contentPlacementOffset={-12}
|
||||
contentPosition="left"
|
||||
isOpen={false}
|
||||
onToggleActive={[Function]}
|
||||
|
|
|
|||
|
|
@ -87,10 +87,11 @@
|
|||
|
||||
.closeLight {
|
||||
color: var(--matterColorAnti);
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:enabled:hover,
|
||||
&:enabled:active {
|
||||
color: var(--matterColor);
|
||||
color: var(--matterColorLight);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,33 +3,22 @@ import { FormattedDate, FormattedMessage } from 'react-intl';
|
|||
import classNames from 'classnames';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { createSlug } from '../../util/urlHelpers';
|
||||
import { ensureListing, ensureTransaction, ensureBooking, ensureUser } from '../../util/data';
|
||||
import { ensureListing, ensureTransaction, ensureUser } from '../../util/data';
|
||||
import { BookingBreakdown, NamedLink, ResponsiveImage, AvatarMedium } from '../../components';
|
||||
|
||||
import css from './OrderDetailsPanel.css';
|
||||
|
||||
const breakdown = transaction => {
|
||||
const tx = ensureTransaction(transaction);
|
||||
const booking = ensureBooking(tx.booking);
|
||||
const bookingStart = booking.attributes.start;
|
||||
const bookingEnd = booking.attributes.end;
|
||||
const payinTotal = tx.attributes.payinTotal;
|
||||
const lineItems = tx.attributes.lineItems;
|
||||
const loaded = transaction && transaction.id && transaction.booking && transaction.booking.id;
|
||||
|
||||
if (!bookingStart || !bookingEnd || !payinTotal || !lineItems) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<BookingBreakdown
|
||||
transactionState={tx.attributes.state}
|
||||
className={css.receipt}
|
||||
bookingStart={bookingStart}
|
||||
bookingEnd={bookingEnd}
|
||||
payinTotal={payinTotal}
|
||||
lineItems={lineItems}
|
||||
userRole="customer"
|
||||
/>
|
||||
);
|
||||
return loaded
|
||||
? <BookingBreakdown
|
||||
className={css.receipt}
|
||||
userRole="customer"
|
||||
transaction={transaction}
|
||||
booking={transaction.booking}
|
||||
/>
|
||||
: null;
|
||||
};
|
||||
|
||||
const orderTitle = (orderState, listingLink, customerName, lastTransition) => {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,10 @@ describe('OrderDetailsPanel', () => {
|
|||
id: 'order-tx',
|
||||
state: 'state/preauthorized',
|
||||
total: new Money(16500, 'USD'),
|
||||
booking: createBooking(
|
||||
'booking1',
|
||||
new Date(Date.UTC(2017, 5, 10)),
|
||||
new Date(Date.UTC(2017, 5, 13))
|
||||
),
|
||||
booking: createBooking('booking1', {
|
||||
start: new Date(Date.UTC(2017, 5, 10)),
|
||||
end: new Date(Date.UTC(2017, 5, 13)),
|
||||
}),
|
||||
listing: createListing('listing1'),
|
||||
provider: createUser('provider'),
|
||||
customer: createUser('customer'),
|
||||
|
|
@ -31,17 +30,16 @@ describe('OrderDetailsPanel', () => {
|
|||
id: 'order-tx',
|
||||
state: 'state/preauthorized',
|
||||
total: new Money(16500, 'USD'),
|
||||
booking: createBooking(
|
||||
'booking1',
|
||||
new Date(Date.UTC(2017, 5, 10)),
|
||||
new Date(Date.UTC(2017, 5, 13))
|
||||
),
|
||||
booking: createBooking('booking1', {
|
||||
start: new Date(Date.UTC(2017, 5, 10)),
|
||||
end: new Date(Date.UTC(2017, 5, 13)),
|
||||
}),
|
||||
listing: createListing('listing1'),
|
||||
provider: createUser('provider'),
|
||||
customer: createUser('customer'),
|
||||
});
|
||||
const panel = shallow(<OrderDetailsPanel transaction={tx} />);
|
||||
const breakdownProps = panel.find(BookingBreakdown).props();
|
||||
expect(breakdownProps.payinTotal).toEqual(new Money(16500, 'USD'));
|
||||
expect(breakdownProps.transaction.attributes.payinTotal).toEqual(new Money(16500, 'USD'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,42 +89,119 @@ exports[`OrderDetailsPanel matches snapshot 1`] = `
|
|||
values={Object {}} />
|
||||
</h3>
|
||||
<BookingBreakdown
|
||||
bookingEnd={2017-06-13T00:00:00.000Z}
|
||||
bookingStart={2017-06-10T00:00:00.000Z}
|
||||
lineItems={
|
||||
Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
booking={
|
||||
Object {
|
||||
"attributes": Object {
|
||||
"end": 2017-06-13T00:00:00.000Z,
|
||||
"start": 2017-06-10T00:00:00.000Z,
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "booking1",
|
||||
},
|
||||
"type": "booking",
|
||||
}
|
||||
}
|
||||
transaction={
|
||||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2017-06-01T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "3",
|
||||
"unitPrice": Money {
|
||||
"amount": 5500,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"code": "line-item/provider-commission",
|
||||
"lineTotal": Money {
|
||||
"amount": -100,
|
||||
"currency": "USD",
|
||||
},
|
||||
"unitPrice": Money {
|
||||
"amount": -100,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
],
|
||||
"payinTotal": Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "3",
|
||||
"unitPrice": Money {
|
||||
"amount": 5500,
|
||||
"payoutTotal": Money {
|
||||
"amount": 16400,
|
||||
"currency": "USD",
|
||||
},
|
||||
"state": "state/preauthorized",
|
||||
},
|
||||
Object {
|
||||
"code": "line-item/provider-commission",
|
||||
"lineTotal": Money {
|
||||
"amount": -100,
|
||||
"currency": "USD",
|
||||
"booking": Object {
|
||||
"attributes": Object {
|
||||
"end": 2017-06-13T00:00:00.000Z,
|
||||
"start": 2017-06-10T00:00:00.000Z,
|
||||
},
|
||||
"unitPrice": Money {
|
||||
"amount": -100,
|
||||
"currency": "USD",
|
||||
"id": UUID {
|
||||
"uuid": "booking1",
|
||||
},
|
||||
"type": "booking",
|
||||
},
|
||||
]
|
||||
}
|
||||
payinTotal={
|
||||
Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
"customer": Object {
|
||||
"attributes": Object {
|
||||
"profile": Object {
|
||||
"abbreviatedName": "customer abbreviated name",
|
||||
"displayName": "customer display name",
|
||||
},
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "customer",
|
||||
},
|
||||
"type": "user",
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "order-tx",
|
||||
},
|
||||
"listing": Object {
|
||||
"attributes": Object {
|
||||
"address": "listing1 address",
|
||||
"description": "listing1 description",
|
||||
"geolocation": LatLng {
|
||||
"lat": 40,
|
||||
"lng": 60,
|
||||
},
|
||||
"open": true,
|
||||
"price": Money {
|
||||
"amount": 5500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"title": "listing1 title",
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "listing1",
|
||||
},
|
||||
"type": "listing",
|
||||
},
|
||||
"provider": Object {
|
||||
"attributes": Object {
|
||||
"profile": Object {
|
||||
"abbreviatedName": "provider abbreviated name",
|
||||
"displayName": "provider display name",
|
||||
},
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "provider",
|
||||
},
|
||||
"type": "user",
|
||||
},
|
||||
"type": "transaction",
|
||||
}
|
||||
}
|
||||
transactionState="state/preauthorized"
|
||||
userRole="customer" />
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -186,42 +263,119 @@ exports[`OrderDetailsPanel matches snapshot 1`] = `
|
|||
values={Object {}} />
|
||||
</h3>
|
||||
<BookingBreakdown
|
||||
bookingEnd={2017-06-13T00:00:00.000Z}
|
||||
bookingStart={2017-06-10T00:00:00.000Z}
|
||||
lineItems={
|
||||
Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
booking={
|
||||
Object {
|
||||
"attributes": Object {
|
||||
"end": 2017-06-13T00:00:00.000Z,
|
||||
"start": 2017-06-10T00:00:00.000Z,
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "booking1",
|
||||
},
|
||||
"type": "booking",
|
||||
}
|
||||
}
|
||||
transaction={
|
||||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2017-06-01T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "3",
|
||||
"unitPrice": Money {
|
||||
"amount": 5500,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"code": "line-item/provider-commission",
|
||||
"lineTotal": Money {
|
||||
"amount": -100,
|
||||
"currency": "USD",
|
||||
},
|
||||
"unitPrice": Money {
|
||||
"amount": -100,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
],
|
||||
"payinTotal": Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "3",
|
||||
"unitPrice": Money {
|
||||
"amount": 5500,
|
||||
"payoutTotal": Money {
|
||||
"amount": 16400,
|
||||
"currency": "USD",
|
||||
},
|
||||
"state": "state/preauthorized",
|
||||
},
|
||||
Object {
|
||||
"code": "line-item/provider-commission",
|
||||
"lineTotal": Money {
|
||||
"amount": -100,
|
||||
"currency": "USD",
|
||||
"booking": Object {
|
||||
"attributes": Object {
|
||||
"end": 2017-06-13T00:00:00.000Z,
|
||||
"start": 2017-06-10T00:00:00.000Z,
|
||||
},
|
||||
"unitPrice": Money {
|
||||
"amount": -100,
|
||||
"currency": "USD",
|
||||
"id": UUID {
|
||||
"uuid": "booking1",
|
||||
},
|
||||
"type": "booking",
|
||||
},
|
||||
]
|
||||
}
|
||||
payinTotal={
|
||||
Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
"customer": Object {
|
||||
"attributes": Object {
|
||||
"profile": Object {
|
||||
"abbreviatedName": "customer abbreviated name",
|
||||
"displayName": "customer display name",
|
||||
},
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "customer",
|
||||
},
|
||||
"type": "user",
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "order-tx",
|
||||
},
|
||||
"listing": Object {
|
||||
"attributes": Object {
|
||||
"address": "listing1 address",
|
||||
"description": "listing1 description",
|
||||
"geolocation": LatLng {
|
||||
"lat": 40,
|
||||
"lng": 60,
|
||||
},
|
||||
"open": true,
|
||||
"price": Money {
|
||||
"amount": 5500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"title": "listing1 title",
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "listing1",
|
||||
},
|
||||
"type": "listing",
|
||||
},
|
||||
"provider": Object {
|
||||
"attributes": Object {
|
||||
"profile": Object {
|
||||
"abbreviatedName": "provider abbreviated name",
|
||||
"displayName": "provider display name",
|
||||
},
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "provider",
|
||||
},
|
||||
"type": "user",
|
||||
},
|
||||
"type": "transaction",
|
||||
}
|
||||
}
|
||||
transactionState="state/preauthorized"
|
||||
userRole="customer" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
}
|
||||
|
||||
.scrollingDisabled {
|
||||
/* position: fixed and width are added to prevent iOS from scrolling content */
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { FormattedDate, FormattedMessage } from 'react-intl';
|
|||
import classNames from 'classnames';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { createSlug } from '../../util/urlHelpers';
|
||||
import { ensureListing, ensureTransaction, ensureBooking, ensureUser } from '../../util/data';
|
||||
import { ensureListing, ensureTransaction, ensureUser } from '../../util/data';
|
||||
import {
|
||||
AvatarLarge,
|
||||
BookingBreakdown,
|
||||
|
|
@ -16,28 +16,15 @@ import {
|
|||
import css from './SaleDetailsPanel.css';
|
||||
|
||||
const breakdown = transaction => {
|
||||
const tx = ensureTransaction(transaction);
|
||||
const booking = ensureBooking(tx.booking);
|
||||
const bookingStart = booking.attributes.start;
|
||||
const bookingEnd = booking.attributes.end;
|
||||
const payinTotal = tx.attributes.payinTotal;
|
||||
const payoutTotal = tx.attributes.payoutTotal;
|
||||
const lineItems = tx.attributes.lineItems;
|
||||
const loaded = transaction && transaction.id && transaction.booking && transaction.booking.id;
|
||||
|
||||
if (!bookingStart || !bookingEnd || !payinTotal || !payoutTotal || !lineItems) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<BookingBreakdown
|
||||
transactionState={tx.attributes.state}
|
||||
bookingStart={bookingStart}
|
||||
bookingEnd={bookingEnd}
|
||||
payinTotal={payinTotal}
|
||||
payoutTotal={payoutTotal}
|
||||
lineItems={lineItems}
|
||||
userRole="provider"
|
||||
/>
|
||||
);
|
||||
return loaded
|
||||
? <BookingBreakdown
|
||||
userRole="provider"
|
||||
transaction={transaction}
|
||||
booking={transaction.booking}
|
||||
/>
|
||||
: null;
|
||||
};
|
||||
|
||||
const saleTitle = (saleState, listingLink, customerName) => {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,10 @@ describe('SaleDetailsPanel', () => {
|
|||
state: 'state/preauthorized',
|
||||
total: new Money(16500, 'USD'),
|
||||
commission: new Money(1000, 'USD'),
|
||||
booking: createBooking(
|
||||
'booking1',
|
||||
new Date(Date.UTC(2017, 5, 10)),
|
||||
new Date(Date.UTC(2017, 5, 13))
|
||||
),
|
||||
booking: createBooking('booking1', {
|
||||
start: new Date(Date.UTC(2017, 5, 10)),
|
||||
end: new Date(Date.UTC(2017, 5, 13)),
|
||||
}),
|
||||
listing: createListing('listing1'),
|
||||
customer: createUser('customer1'),
|
||||
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
|
||||
|
|
@ -41,11 +40,10 @@ describe('SaleDetailsPanel', () => {
|
|||
state: 'state/preauthorized',
|
||||
total: new Money(16500, 'USD'),
|
||||
commission: new Money(1000, 'USD'),
|
||||
booking: createBooking(
|
||||
'booking1',
|
||||
new Date(Date.UTC(2017, 5, 10)),
|
||||
new Date(Date.UTC(2017, 5, 13))
|
||||
),
|
||||
booking: createBooking('booking1', {
|
||||
start: new Date(Date.UTC(2017, 5, 10)),
|
||||
end: new Date(Date.UTC(2017, 5, 13)),
|
||||
}),
|
||||
listing: createListing('listing1'),
|
||||
customer: createUser('customer1'),
|
||||
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
|
||||
|
|
@ -60,6 +58,6 @@ describe('SaleDetailsPanel', () => {
|
|||
const breakdownProps = panel.find(BookingBreakdown).props();
|
||||
|
||||
// Total price for the provider should be transaction total minus the commission.
|
||||
expect(breakdownProps.payoutTotal).toEqual(new Money(15500, 'USD'));
|
||||
expect(breakdownProps.transaction.attributes.payoutTotal).toEqual(new Money(15500, 'USD'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -110,48 +110,108 @@ exports[`SaleDetailsPanel matches snapshot 1`] = `
|
|||
values={Object {}} />
|
||||
</h3>
|
||||
<BookingBreakdown
|
||||
bookingEnd={2017-06-13T00:00:00.000Z}
|
||||
bookingStart={2017-06-10T00:00:00.000Z}
|
||||
lineItems={
|
||||
Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
booking={
|
||||
Object {
|
||||
"attributes": Object {
|
||||
"end": 2017-06-13T00:00:00.000Z,
|
||||
"start": 2017-06-10T00:00:00.000Z,
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "booking1",
|
||||
},
|
||||
"type": "booking",
|
||||
}
|
||||
}
|
||||
transaction={
|
||||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2017-06-10T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "3",
|
||||
"unitPrice": Money {
|
||||
"amount": 5500,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"code": "line-item/provider-commission",
|
||||
"lineTotal": Money {
|
||||
"amount": -1000,
|
||||
"currency": "USD",
|
||||
},
|
||||
"unitPrice": Money {
|
||||
"amount": -1000,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
],
|
||||
"payinTotal": Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "3",
|
||||
"unitPrice": Money {
|
||||
"amount": 5500,
|
||||
"payoutTotal": Money {
|
||||
"amount": 15500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"state": "state/preauthorized",
|
||||
},
|
||||
Object {
|
||||
"code": "line-item/provider-commission",
|
||||
"lineTotal": Money {
|
||||
"amount": -1000,
|
||||
"currency": "USD",
|
||||
"booking": Object {
|
||||
"attributes": Object {
|
||||
"end": 2017-06-13T00:00:00.000Z,
|
||||
"start": 2017-06-10T00:00:00.000Z,
|
||||
},
|
||||
"unitPrice": Money {
|
||||
"amount": -1000,
|
||||
"currency": "USD",
|
||||
"id": UUID {
|
||||
"uuid": "booking1",
|
||||
},
|
||||
"type": "booking",
|
||||
},
|
||||
]
|
||||
}
|
||||
payinTotal={
|
||||
Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
"customer": Object {
|
||||
"attributes": Object {
|
||||
"profile": Object {
|
||||
"abbreviatedName": "customer1 abbreviated name",
|
||||
"displayName": "customer1 display name",
|
||||
},
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "customer1",
|
||||
},
|
||||
"type": "user",
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "sale-tx",
|
||||
},
|
||||
"listing": Object {
|
||||
"attributes": Object {
|
||||
"address": "listing1 address",
|
||||
"description": "listing1 description",
|
||||
"geolocation": LatLng {
|
||||
"lat": 40,
|
||||
"lng": 60,
|
||||
},
|
||||
"open": true,
|
||||
"price": Money {
|
||||
"amount": 5500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"title": "listing1 title",
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "listing1",
|
||||
},
|
||||
"type": "listing",
|
||||
},
|
||||
"provider": null,
|
||||
"type": "transaction",
|
||||
}
|
||||
}
|
||||
payoutTotal={
|
||||
Money {
|
||||
"amount": 15500,
|
||||
"currency": "USD",
|
||||
}
|
||||
}
|
||||
transactionState="state/preauthorized"
|
||||
userRole="provider" />
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -183,48 +243,108 @@ exports[`SaleDetailsPanel matches snapshot 1`] = `
|
|||
</h3>
|
||||
<div>
|
||||
<BookingBreakdown
|
||||
bookingEnd={2017-06-13T00:00:00.000Z}
|
||||
bookingStart={2017-06-10T00:00:00.000Z}
|
||||
lineItems={
|
||||
Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
booking={
|
||||
Object {
|
||||
"attributes": Object {
|
||||
"end": 2017-06-13T00:00:00.000Z,
|
||||
"start": 2017-06-10T00:00:00.000Z,
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "booking1",
|
||||
},
|
||||
"type": "booking",
|
||||
}
|
||||
}
|
||||
transaction={
|
||||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2017-06-10T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "3",
|
||||
"unitPrice": Money {
|
||||
"amount": 5500,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"code": "line-item/provider-commission",
|
||||
"lineTotal": Money {
|
||||
"amount": -1000,
|
||||
"currency": "USD",
|
||||
},
|
||||
"unitPrice": Money {
|
||||
"amount": -1000,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
],
|
||||
"payinTotal": Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "3",
|
||||
"unitPrice": Money {
|
||||
"amount": 5500,
|
||||
"payoutTotal": Money {
|
||||
"amount": 15500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"state": "state/preauthorized",
|
||||
},
|
||||
Object {
|
||||
"code": "line-item/provider-commission",
|
||||
"lineTotal": Money {
|
||||
"amount": -1000,
|
||||
"currency": "USD",
|
||||
"booking": Object {
|
||||
"attributes": Object {
|
||||
"end": 2017-06-13T00:00:00.000Z,
|
||||
"start": 2017-06-10T00:00:00.000Z,
|
||||
},
|
||||
"unitPrice": Money {
|
||||
"amount": -1000,
|
||||
"currency": "USD",
|
||||
"id": UUID {
|
||||
"uuid": "booking1",
|
||||
},
|
||||
"type": "booking",
|
||||
},
|
||||
]
|
||||
}
|
||||
payinTotal={
|
||||
Money {
|
||||
"amount": 16500,
|
||||
"currency": "USD",
|
||||
"customer": Object {
|
||||
"attributes": Object {
|
||||
"profile": Object {
|
||||
"abbreviatedName": "customer1 abbreviated name",
|
||||
"displayName": "customer1 display name",
|
||||
},
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "customer1",
|
||||
},
|
||||
"type": "user",
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "sale-tx",
|
||||
},
|
||||
"listing": Object {
|
||||
"attributes": Object {
|
||||
"address": "listing1 address",
|
||||
"description": "listing1 description",
|
||||
"geolocation": LatLng {
|
||||
"lat": 40,
|
||||
"lng": 60,
|
||||
},
|
||||
"open": true,
|
||||
"price": Money {
|
||||
"amount": 5500,
|
||||
"currency": "USD",
|
||||
},
|
||||
"title": "listing1 title",
|
||||
},
|
||||
"id": UUID {
|
||||
"uuid": "listing1",
|
||||
},
|
||||
"type": "listing",
|
||||
},
|
||||
"provider": null,
|
||||
"type": "transaction",
|
||||
}
|
||||
}
|
||||
payoutTotal={
|
||||
Money {
|
||||
"amount": 15500,
|
||||
"currency": "USD",
|
||||
}
|
||||
}
|
||||
transactionState="state/preauthorized"
|
||||
userRole="provider" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
background-color: var(--marketplaceColor);
|
||||
color: var(--matterColorLight);
|
||||
|
||||
box-shadow: var(--boxShadowPopupLight);
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
box-shadow: var(--boxShadowPopup);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
border-radius: 4px;
|
||||
box-shadow: var(--boxShadowPopupLight);
|
||||
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
box-shadow: var(--boxShadowPopup);
|
||||
|
|
@ -44,11 +46,6 @@
|
|||
margin-bottom: 0;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
/* Borders */
|
||||
border-style: solid;
|
||||
border-color: var(--matterColorNegative);
|
||||
border-width: 1px;
|
||||
|
||||
/* Overwrite dimensions from font styles */
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 0;
|
||||
|
|
@ -128,6 +125,12 @@
|
|||
background-position: center;
|
||||
border-bottom-left-radius: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.paginationNext {
|
||||
|
|
@ -139,6 +142,13 @@
|
|||
background-position: center;
|
||||
border-bottom-right-radius: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
opacity: 0.5;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.caretShadow {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import css from './StripeBankAccountTokenInputField.css';
|
||||
|
||||
const StripeBankAccountRequiredInput = props => {
|
||||
const {
|
||||
className,
|
||||
rootClassName,
|
||||
inputType,
|
||||
formName,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
isTouched,
|
||||
showStripeError,
|
||||
inputError,
|
||||
} = props;
|
||||
|
||||
const showInputError = isTouched && !!inputError;
|
||||
|
||||
const classes = classNames(rootClassName || css.input, className, {
|
||||
[css.inputSuccess]: !!value,
|
||||
[css.inputError]: showInputError || showStripeError,
|
||||
});
|
||||
|
||||
const inputProps = {
|
||||
className: classes,
|
||||
id: `${formName}.bankAccountToken.${inputType}`,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
};
|
||||
|
||||
const errorMessage = <p className={css.error}>{inputError}</p>;
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<label htmlFor={inputProps.id}>
|
||||
<FormattedMessage id={`StripeBankAccountTokenInputField.${inputType}.label`} />
|
||||
</label>
|
||||
<input {...inputProps} />
|
||||
{showInputError ? errorMessage : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { bool, func, string } = PropTypes;
|
||||
|
||||
StripeBankAccountRequiredInput.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
inputError: null,
|
||||
};
|
||||
|
||||
StripeBankAccountRequiredInput.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
inputType: string.isRequired,
|
||||
formName: string.isRequired,
|
||||
value: string.isRequired,
|
||||
placeholder: string.isRequired,
|
||||
onChange: func.isRequired,
|
||||
onFocus: func.isRequired,
|
||||
onBlur: func.isRequired,
|
||||
isTouched: bool.isRequired,
|
||||
showStripeError: bool.isRequired,
|
||||
inputError: string,
|
||||
};
|
||||
|
||||
export default StripeBankAccountRequiredInput;
|
||||
|
|
@ -2,31 +2,34 @@
|
|||
import React from 'react';
|
||||
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
|
||||
import { Button } from '../../components';
|
||||
import { stripeCountryConfigs } from './StripeBankAccountTokenInputField.util';
|
||||
import StripeBankAccountTokenInputField from './StripeBankAccountTokenInputField';
|
||||
import * as validators from '../../util/validators';
|
||||
|
||||
const FormComponentEur = props => {
|
||||
const { form, handleSubmit } = props;
|
||||
const country = 'DE';
|
||||
const currency = 'EUR';
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<StripeBankAccountTokenInputField
|
||||
id={`${form}.token`}
|
||||
name="token"
|
||||
country={country}
|
||||
currency={currency}
|
||||
routingNumberId={`${form}.routingNumber`}
|
||||
accountNumberId={`${form}.accountNumber`}
|
||||
validate={validators.required(' ')}
|
||||
/>
|
||||
<Button style={{ marginTop: 24 }} type="submit">Submit</Button>
|
||||
</form>
|
||||
);
|
||||
const formComponent = country => {
|
||||
const FormComponent = props => {
|
||||
const { form, handleSubmit } = props;
|
||||
const currency = stripeCountryConfigs(country).currency;
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<StripeBankAccountTokenInputField
|
||||
id={`${form}.token`}
|
||||
name="token"
|
||||
country={country}
|
||||
currency={currency}
|
||||
formName={form}
|
||||
validate={validators.required(' ')}
|
||||
/>
|
||||
<Button style={{ marginTop: 24 }} type="submit">Submit</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
FormComponent.propTypes = formPropTypes;
|
||||
return FormComponent;
|
||||
};
|
||||
|
||||
FormComponentEur.propTypes = formPropTypes;
|
||||
|
||||
// DE
|
||||
const FormComponentEur = formComponent('DE');
|
||||
const formNameEur = 'Styleguide.StripeBankAccountTokenInputField.formEur';
|
||||
const FormEur = reduxForm({ form: formNameEur })(FormComponentEur);
|
||||
|
||||
|
|
@ -37,34 +40,14 @@ export const DE_EUR = {
|
|||
console.log('values changed to:', values);
|
||||
},
|
||||
onSubmit: values => {
|
||||
console.log('values changed to:', values);
|
||||
console.log('values submitted:', values);
|
||||
},
|
||||
},
|
||||
group: 'custom inputs',
|
||||
};
|
||||
|
||||
const FormComponentUsd = props => {
|
||||
const { form, handleSubmit } = props;
|
||||
const country = 'US';
|
||||
const currency = 'USD';
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<StripeBankAccountTokenInputField
|
||||
id={`${form}.token`}
|
||||
name="token"
|
||||
country={country}
|
||||
currency={currency}
|
||||
routingNumberId={`${form}.routingNumber`}
|
||||
accountNumberId={`${form}.accountNumber`}
|
||||
validate={validators.required(' ')}
|
||||
/>
|
||||
<Button style={{ marginTop: 24 }} type="submit">Submit</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
FormComponentUsd.propTypes = formPropTypes;
|
||||
|
||||
// US
|
||||
const FormComponentUsd = formComponent('US');
|
||||
const formNameUsd = 'Styleguide.StripeBankAccountTokenInputField.formUsd';
|
||||
const FormUsd = reduxForm({ form: formNameUsd })(FormComponentUsd);
|
||||
|
||||
|
|
@ -75,7 +58,43 @@ export const US_USD = {
|
|||
console.log('values changed to:', values);
|
||||
},
|
||||
onSubmit: values => {
|
||||
console.log('values changed to:', values);
|
||||
console.log('values submitted:', values);
|
||||
},
|
||||
},
|
||||
group: 'custom inputs',
|
||||
};
|
||||
|
||||
// GB
|
||||
const FormComponentGbGbp = formComponent('GB');
|
||||
const formNameGbp = 'Styleguide.StripeBankAccountTokenInputField.formGbGbp';
|
||||
const formGbGbp = reduxForm({ form: formNameGbp })(FormComponentGbGbp);
|
||||
|
||||
export const GB_GBP = {
|
||||
component: formGbGbp,
|
||||
props: {
|
||||
onChange: values => {
|
||||
console.log('values changed to:', values);
|
||||
},
|
||||
onSubmit: values => {
|
||||
console.log('values submitted:', values);
|
||||
},
|
||||
},
|
||||
group: 'custom inputs',
|
||||
};
|
||||
|
||||
// AU
|
||||
const FormComponentAuAud = formComponent('AU');
|
||||
const formNameAuAud = 'Styleguide.StripeBankAccountTokenInputField.formAuAud';
|
||||
const formAuAud = reduxForm({ form: formNameAuAud })(FormComponentAuAud);
|
||||
|
||||
export const AU_AUD = {
|
||||
component: formAuAud,
|
||||
props: {
|
||||
onChange: values => {
|
||||
console.log('values changed to:', values);
|
||||
},
|
||||
onSubmit: values => {
|
||||
console.log('values submitted:', values);
|
||||
},
|
||||
},
|
||||
group: 'custom inputs',
|
||||
|
|
|
|||
|
|
@ -6,10 +6,20 @@ import classNames from 'classnames';
|
|||
import { debounce } from 'lodash';
|
||||
import config from '../../config';
|
||||
|
||||
import {
|
||||
BANK_ACCOUNT_INPUTS,
|
||||
cleanedString,
|
||||
createToken,
|
||||
formatFieldMessage,
|
||||
requiredInputs,
|
||||
mapInputsToStripeAccountKeys,
|
||||
supportedCountries,
|
||||
translateStripeError,
|
||||
validateInput,
|
||||
} from './StripeBankAccountTokenInputField.util';
|
||||
import StripeBankAccountRequiredInput from './StripeBankAccountRequiredInput';
|
||||
import css from './StripeBankAccountTokenInputField.css';
|
||||
|
||||
const supportedCountries = config.stripe.supportedCountries.map(c => c.code);
|
||||
|
||||
// Since redux-form tracks the onBlur event for marking the field as
|
||||
// touched (which triggers possible error validation rendering), only
|
||||
// trigger the event asynchronously when no other input within this
|
||||
|
|
@ -18,90 +28,27 @@ const supportedCountries = config.stripe.supportedCountries.map(c => c.code);
|
|||
// This prevents showing the validation error when the user selects a
|
||||
// value and moves on to another input within this component.
|
||||
const BLUR_TIMEOUT = 100;
|
||||
|
||||
const DEBOUNCE_WAIT_TIME = 1000;
|
||||
|
||||
// Remove all whitespace from the given string
|
||||
const cleanedString = str => {
|
||||
return str ? str.replace(/\s/g, '') : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a single-use token from the given bank account data
|
||||
*
|
||||
* See: https://stripe.com/docs/stripe.js#collecting-bank-account-details
|
||||
*
|
||||
* @param {Object} data - bank account data sent to Stripe
|
||||
*
|
||||
* @return {Promise<String>} Promise that resolves with the bank
|
||||
* account token or rejects when the token creation fails
|
||||
*/
|
||||
const createToken = data =>
|
||||
new Promise((resolve, reject) => {
|
||||
window.Stripe.bankAccount.createToken(data, (status, response) => {
|
||||
if (response.error) {
|
||||
const e = new Error(response.error.message);
|
||||
e.stripeError = response.error;
|
||||
reject(e);
|
||||
} else {
|
||||
resolve(response.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// In addition to the bank account number, Stripe requires a routing
|
||||
// number when the currency is not EUR. When it is EUR, the account
|
||||
// number is expected to be an IBAN number.
|
||||
//
|
||||
// See: https://stripe.com/docs/stripe.js#bank-account-createToken
|
||||
const requiresRoutingNumber = currency => currency !== 'EUR';
|
||||
|
||||
const stripeErrorTranslation = (country, currency, intl, stripeError) => {
|
||||
console.error('Stripe error:', stripeError); // eslint-disable-line no-console
|
||||
const routingNumberRequired = requiresRoutingNumber(currency);
|
||||
|
||||
const translationId = routingNumberRequired
|
||||
? 'StripeBankAccountTokenInputField.genericStripeError'
|
||||
: 'StripeBankAccountTokenInputField.genericStripeErrorIban';
|
||||
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: translationId,
|
||||
defaultMessage: stripeError.message,
|
||||
},
|
||||
{ country, currency }
|
||||
);
|
||||
};
|
||||
|
||||
const isRoutingNumberValid = (routingNumber, country) =>
|
||||
window.Stripe.bankAccount.validateRoutingNumber(routingNumber, country);
|
||||
|
||||
const isBankAccountNumberValid = (accountNumber, country) =>
|
||||
window.Stripe.bankAccount.validateAccountNumber(accountNumber, country);
|
||||
|
||||
class TokenInputFieldComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { currency, intl } = props;
|
||||
const routingNumberRequired = requiresRoutingNumber(currency);
|
||||
this.routingNumberRequiredMessage = intl.formatMessage({
|
||||
id: 'StripeBankAccountTokenInputField.routingNumberRequired',
|
||||
});
|
||||
this.accountNumberRequiredMessage = intl.formatMessage({
|
||||
id: routingNumberRequired
|
||||
? 'StripeBankAccountTokenInputField.accountNumberRequired'
|
||||
: 'StripeBankAccountTokenInputField.accountNumberRequiredIban',
|
||||
});
|
||||
const intl = props.intl;
|
||||
|
||||
// Initial state is needed when country (and currency) changes and values need to be cleared.
|
||||
this.initialState = {
|
||||
routingNumber: '',
|
||||
routingNumberTouched: false,
|
||||
routingNumberError: this.routingNumberRequiredMessage,
|
||||
accountNumber: '',
|
||||
accountNumberTouched: false,
|
||||
accountNumberError: this.accountNumberRequiredMessage,
|
||||
stripeError: null,
|
||||
};
|
||||
|
||||
// Fill initialState with input type specific data
|
||||
BANK_ACCOUNT_INPUTS.forEach(inputType => {
|
||||
this.initialState[inputType] = {
|
||||
value: '',
|
||||
touched: false,
|
||||
error: formatFieldMessage(intl, inputType, 'required'),
|
||||
};
|
||||
});
|
||||
|
||||
this.state = this.initialState;
|
||||
this.blurTimeoutId = null;
|
||||
|
||||
|
|
@ -119,13 +66,11 @@ class TokenInputFieldComponent extends Component {
|
|||
|
||||
this.requestToken = debounce(this.requestToken.bind(this), DEBOUNCE_WAIT_TIME);
|
||||
|
||||
this.handleRoutingNumberChange = this.handleRoutingNumberChange.bind(this);
|
||||
this.handleRoutingNumberFocus = this.handleRoutingNumberFocus.bind(this);
|
||||
this.handleRoutingNumberBlur = this.handleRoutingNumberBlur.bind(this);
|
||||
this.handleAccountNumberChange = this.handleAccountNumberChange.bind(this);
|
||||
this.handleAccountNumberFocus = this.handleAccountNumberFocus.bind(this);
|
||||
this.handleAccountNumberBlur = this.handleAccountNumberBlur.bind(this);
|
||||
this.handleInputChange = this.handleInputChange.bind(this);
|
||||
this.handleInputFocus = this.handleInputFocus.bind(this);
|
||||
this.handleInputBlur = this.handleInputBlur.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!window.Stripe) {
|
||||
throw new Error('Stripe must be loaded for StripeBankAccountTokenInputField');
|
||||
|
|
@ -133,16 +78,18 @@ class TokenInputFieldComponent extends Component {
|
|||
this.stripe = window.Stripe(config.stripe.publishableKey);
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const countryChanged = nextProps.country !== this.props.country;
|
||||
const currencyChanged = nextProps.currency !== this.props.currency;
|
||||
if (countryChanged || currencyChanged) {
|
||||
// Clear the possible routing and bank account numbers from the
|
||||
// state if the given country or currency changes.
|
||||
// Clear the possible input values from the state
|
||||
// if the given country or currency changes.
|
||||
this.setState(this.initialState);
|
||||
nextProps.input.onChange('');
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
window.clearTimeout(this.blurTimeoutId);
|
||||
|
|
@ -155,46 +102,61 @@ class TokenInputFieldComponent extends Component {
|
|||
* events for the parent form to handle.
|
||||
*
|
||||
*
|
||||
* @param {String} accountNumber - bank account number
|
||||
* @param {String} routingNumber - routing number for non-IBAN bank accounts
|
||||
* @param {Object} values - values from different input types
|
||||
*/
|
||||
requestToken(accountNumber, routingNumber) {
|
||||
requestToken(values) {
|
||||
const { country, currency, input: { onChange }, intl } = this.props;
|
||||
const routingNumberRequired = requiresRoutingNumber(currency);
|
||||
|
||||
// First we have to clear the current token value so the parent
|
||||
// form doesn't submit with an old value.
|
||||
onChange('');
|
||||
|
||||
const numbersMissing = !accountNumber || (routingNumberRequired && !routingNumber);
|
||||
const numbersInvalid = this.state.accountNumberError ||
|
||||
(routingNumberRequired && this.state.routingNumberError);
|
||||
const inputsNeeded = requiredInputs(country);
|
||||
const missingValues = inputsNeeded.filter(inputType => !values[inputType]);
|
||||
const invalidValues = inputsNeeded.filter(inputType => !!this.state[inputType].error);
|
||||
|
||||
const numbersMissing = missingValues.length > 0;
|
||||
const numbersInvalid = invalidValues.length > 0;
|
||||
|
||||
if (numbersMissing || numbersInvalid) {
|
||||
// Incomplete/invalid info, not requesting token
|
||||
return;
|
||||
}
|
||||
|
||||
const accountData = {
|
||||
country: this.props.country,
|
||||
currency: this.props.currency,
|
||||
|
||||
// Stripe fails if there are spaces within the number, this is
|
||||
// why we have to clean it up first.
|
||||
account_number: cleanedString(accountNumber),
|
||||
// Gather data to be sent to Stripe (to create bank account)
|
||||
let accountData = {
|
||||
country,
|
||||
currency,
|
||||
};
|
||||
if (routingNumberRequired) {
|
||||
accountData.routing_number = cleanedString(routingNumber);
|
||||
}
|
||||
|
||||
// Include input values with correct stripe keys
|
||||
inputsNeeded.forEach(inputType => {
|
||||
// Stripe fails if there are spaces within the number, this is
|
||||
// why we have to clean value up first.
|
||||
const inputValueObj = mapInputsToStripeAccountKeys(
|
||||
inputType,
|
||||
cleanedString(values[inputType])
|
||||
);
|
||||
accountData = { ...accountData, ...inputValueObj };
|
||||
});
|
||||
|
||||
createToken(accountData)
|
||||
.then(token => {
|
||||
if (this._isMounted && accountNumber === this.state.accountNumber) {
|
||||
// Handle response only if the current number hasn't changed
|
||||
this.setState({
|
||||
routingNumberError: null,
|
||||
accountNumberError: null,
|
||||
stripeError: null,
|
||||
const changedValues = inputsNeeded.filter(
|
||||
inputType => values[inputType] !== this.state[inputType].value
|
||||
);
|
||||
const valuesAreUnchanged = changedValues.length === 0;
|
||||
|
||||
// Handle response only if the input values haven't changed
|
||||
if (this._isMounted && valuesAreUnchanged) {
|
||||
this.setState(prevState => {
|
||||
const errorsClearedFromInputs = inputsNeeded.map(inputType => {
|
||||
const input = prevState[inputType];
|
||||
return { ...input, error: null };
|
||||
});
|
||||
return { ...errorsClearedFromInputs, stripeError: null };
|
||||
});
|
||||
|
||||
onChange(token);
|
||||
}
|
||||
})
|
||||
|
|
@ -204,92 +166,71 @@ class TokenInputFieldComponent extends Component {
|
|||
}
|
||||
if (this._isMounted) {
|
||||
this.setState({
|
||||
stripeError: stripeErrorTranslation(country, currency, intl, e.stripeError),
|
||||
stripeError: translateStripeError(country, intl, e.stripeError),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
handleRoutingNumberChange(e) {
|
||||
const { country, intl } = this.props;
|
||||
|
||||
handleInputChange(e, inputType, country, intl) {
|
||||
const rawValue = e.target.value;
|
||||
const value = cleanedString(rawValue);
|
||||
let routingNumberError = null;
|
||||
let inputError = null;
|
||||
|
||||
// Validate the changed routing number
|
||||
if (!value) {
|
||||
routingNumberError = this.routingNumberRequiredMessage;
|
||||
} else if (!isRoutingNumberValid(value, country)) {
|
||||
routingNumberError = intl.formatMessage(
|
||||
inputError = intl.formatMessage({
|
||||
id: `StripeBankAccountTokenInputField.${inputType}.required`,
|
||||
});
|
||||
} else if (!validateInput(inputType, value, country, window.Stripe)) {
|
||||
inputError = intl.formatMessage(
|
||||
{
|
||||
id: 'StripeBankAccountTokenInputField.routingNumberInvalid',
|
||||
id: `StripeBankAccountTokenInputField.${inputType}.invalid`,
|
||||
},
|
||||
{ country }
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
routingNumber: rawValue,
|
||||
routingNumberError,
|
||||
stripeError: null,
|
||||
// Save changes to the state
|
||||
this.setState(prevState => {
|
||||
const input = { ...prevState[inputType], value: rawValue, error: inputError };
|
||||
return {
|
||||
[inputType]: input,
|
||||
stripeError: null,
|
||||
};
|
||||
});
|
||||
this.requestToken(this.state.accountNumber, value);
|
||||
|
||||
// Request new bank account token
|
||||
const unChangedValues = requiredInputs(country).reduce(
|
||||
(acc, iType) => ({ ...acc, [iType]: this.state[iType].value }),
|
||||
{}
|
||||
);
|
||||
this.requestToken({ ...unChangedValues, [inputType]: value });
|
||||
}
|
||||
handleRoutingNumberFocus() {
|
||||
|
||||
handleInputFocus() {
|
||||
window.clearTimeout(this.blurTimeoutId);
|
||||
}
|
||||
handleRoutingNumberBlur() {
|
||||
this.setState({ routingNumberTouched: true });
|
||||
|
||||
handleInputBlur(inputType) {
|
||||
this.setState(prevState => {
|
||||
const inputData = { ...prevState[inputType], touched: true };
|
||||
return { [inputType]: inputData };
|
||||
});
|
||||
window.clearTimeout(this.blurTimeoutId);
|
||||
this.blurTimeoutId = window.setTimeout(this.props.input.onBlur, BLUR_TIMEOUT);
|
||||
}
|
||||
handleAccountNumberChange(e) {
|
||||
const { country, intl } = this.props;
|
||||
const rawValue = e.target.value;
|
||||
const value = cleanedString(rawValue);
|
||||
let accountNumberError = null;
|
||||
|
||||
// Validate the changed account number
|
||||
if (!value) {
|
||||
accountNumberError = this.accountNumberRequiredMessage;
|
||||
} else if (!isBankAccountNumberValid(value, country)) {
|
||||
accountNumberError = intl.formatMessage(
|
||||
{
|
||||
id: 'StripeBankAccountTokenInputField.accountNumberInvalid',
|
||||
},
|
||||
{ country }
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
accountNumber: rawValue,
|
||||
accountNumberError,
|
||||
stripeError: null,
|
||||
});
|
||||
this.requestToken(value, this.state.routingNumber);
|
||||
}
|
||||
handleAccountNumberFocus() {
|
||||
window.clearTimeout(this.blurTimeoutId);
|
||||
}
|
||||
handleAccountNumberBlur() {
|
||||
this.setState({ accountNumberTouched: true });
|
||||
window.clearTimeout(this.blurTimeoutId);
|
||||
this.blurTimeoutId = window.setTimeout(this.props.input.onBlur, BLUR_TIMEOUT);
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
country,
|
||||
currency,
|
||||
routingNumberId,
|
||||
accountNumberId,
|
||||
input,
|
||||
formName,
|
||||
meta: formMeta,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
const routingNumberRequired = requiresRoutingNumber(currency);
|
||||
|
||||
if (!supportedCountries.includes(country)) {
|
||||
return (
|
||||
<div className={css.unsupportedCountryError}>
|
||||
|
|
@ -301,80 +242,41 @@ class TokenInputFieldComponent extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
const showRoutingNumberError = routingNumberRequired &&
|
||||
(this.state.routingNumberTouched || formMeta.touched) &&
|
||||
!!this.state.routingNumberError;
|
||||
const showAccountNumberError = (this.state.accountNumberTouched || formMeta.touched) &&
|
||||
!!this.state.accountNumberError;
|
||||
const hasInputErrors = requiredInputs(country).some(inputType => {
|
||||
return (this.state[inputType].touched || formMeta.touched) && !!this.state[inputType].error;
|
||||
});
|
||||
|
||||
// Only show Stripe and form errors when the fields don't have
|
||||
// more specific errors.
|
||||
const showingFieldErrors = showRoutingNumberError || showAccountNumberError;
|
||||
const showingFieldErrors = hasInputErrors;
|
||||
const showStripeError = !!(this.state.stripeError && !showingFieldErrors && formMeta.touched);
|
||||
const showFormError = !!(formMeta.touched &&
|
||||
formMeta.error &&
|
||||
!showingFieldErrors &&
|
||||
!showStripeError);
|
||||
|
||||
const routingNumberPlaceholder = intl.formatMessage({
|
||||
id: 'StripeBankAccountTokenInputField.routingNumberPlaceholder',
|
||||
});
|
||||
const routingInputClasses = classNames(css.input, {
|
||||
[css.inputSuccess]: !!this.state.routingNumber,
|
||||
[css.inputError]: showRoutingNumberError || showStripeError,
|
||||
});
|
||||
const routingNumberInputProps = {
|
||||
className: routingInputClasses,
|
||||
id: routingNumberId,
|
||||
value: this.state.routingNumber,
|
||||
placeholder: routingNumberPlaceholder,
|
||||
onChange: this.handleRoutingNumberChange,
|
||||
onFocus: this.handleRoutingNumberFocus,
|
||||
onBlur: this.handleRoutingNumberBlur,
|
||||
};
|
||||
const routingNumberError = <p className={css.error}>{this.state.routingNumberError}</p>;
|
||||
const routingNumberField = (
|
||||
<div>
|
||||
<label htmlFor={routingNumberId}>
|
||||
<FormattedMessage id="StripeBankAccountTokenInputField.routingNumberLabel" />
|
||||
</label>
|
||||
<input {...routingNumberInputProps} />
|
||||
{showRoutingNumberError ? routingNumberError : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const accountNumberPlaceholder = routingNumberRequired
|
||||
? intl.formatMessage({ id: 'StripeBankAccountTokenInputField.accountNumberPlaceholder' })
|
||||
: intl.formatMessage({ id: 'StripeBankAccountTokenInputField.accountNumberPlaceholderIban' });
|
||||
const accountInputClasses = classNames(css.input, {
|
||||
[css.inputSuccess]: !!this.state.accountNumber,
|
||||
[css.inputError]: showAccountNumberError || showStripeError,
|
||||
});
|
||||
const accountNumberInputProps = {
|
||||
className: accountInputClasses,
|
||||
id: accountNumberId,
|
||||
value: this.state.accountNumber,
|
||||
placeholder: accountNumberPlaceholder,
|
||||
onChange: this.handleAccountNumberChange,
|
||||
onFocus: this.handleAccountNumberFocus,
|
||||
onBlur: this.handleAccountNumberBlur,
|
||||
};
|
||||
const accountNumberLabel = routingNumberRequired
|
||||
? <label className={css.withTopMargin} htmlFor={accountNumberId}>
|
||||
<FormattedMessage id="StripeBankAccountTokenInputField.accountNumberLabel" />
|
||||
</label>
|
||||
: <label htmlFor={accountNumberId}>
|
||||
<FormattedMessage id="StripeBankAccountTokenInputField.accountNumberLabelIban" />
|
||||
</label>;
|
||||
const accountNumberError = <p className={css.error}>{this.state.accountNumberError}</p>;
|
||||
const inputConfiguration = requiredInputs(country);
|
||||
|
||||
return (
|
||||
<div className={classNames(rootClassName || css.root, className)}>
|
||||
{routingNumberRequired ? routingNumberField : null}
|
||||
|
||||
{accountNumberLabel}
|
||||
<input {...accountNumberInputProps} />
|
||||
{showAccountNumberError ? accountNumberError : null}
|
||||
{inputConfiguration.map(inputType => {
|
||||
return (
|
||||
<StripeBankAccountRequiredInput
|
||||
key={inputType}
|
||||
inputType={inputType}
|
||||
formName={formName}
|
||||
value={this.state[inputType].value}
|
||||
placeholder={formatFieldMessage(intl, inputType, 'placeholder')}
|
||||
onChange={e => this.handleInputChange(e, inputType, country, intl)}
|
||||
onFocus={this.handleInputFocus}
|
||||
onBlur={() => this.handleInputBlur(inputType)}
|
||||
isTouched={this.state[inputType].touched || formMeta.touched}
|
||||
showStripeError={showStripeError}
|
||||
inputError={this.state[inputType].error}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{showStripeError ? <p className={css.error}>{this.state.stripeError}</p> : null}
|
||||
{showFormError ? <p className={css.error}>{formMeta.error}</p> : null}
|
||||
|
|
@ -390,12 +292,9 @@ const { string, shape, func, bool } = PropTypes;
|
|||
TokenInputFieldComponent.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
|
||||
country: string.isRequired,
|
||||
currency: string.isRequired,
|
||||
|
||||
routingNumberId: string.isRequired,
|
||||
accountNumberId: string.isRequired,
|
||||
formName: string.isRequired,
|
||||
|
||||
input: shape({
|
||||
onChange: func.isRequired,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
import config from '../../config';
|
||||
|
||||
// Possible inputs Stripe might require for a country
|
||||
|
||||
// Bank account number (used in countries where IBAN is not in use)
|
||||
export const ACCOUNT_NUMBER = 'accountNumber';
|
||||
// Australian equivalent for routing number
|
||||
export const BSB = 'bsb';
|
||||
// International bank account number (e.g. EU countries use this)
|
||||
export const IBAN = 'iban';
|
||||
// Routing number to separate bank account in different areas
|
||||
export const ROUTING_NUMBER = 'routingNumber';
|
||||
// British equivalent for routing number
|
||||
export const SORT_CODE = 'sortCode';
|
||||
|
||||
// Currently supported bank account inputs
|
||||
// the order here matters: account number input is asked after routing number and its equivalents
|
||||
export const BANK_ACCOUNT_INPUTS = [BSB, SORT_CODE, ROUTING_NUMBER, ACCOUNT_NUMBER, IBAN];
|
||||
|
||||
export const supportedCountries = config.stripe.supportedCountries.map(c => c.code);
|
||||
|
||||
/**
|
||||
* Create a single-use token from the given bank account data
|
||||
*
|
||||
* See: https://stripe.com/docs/stripe.js#collecting-bank-account-details
|
||||
*
|
||||
* @param {Object} data - bank account data sent to Stripe
|
||||
*
|
||||
* @return {Promise<String>} Promise that resolves with the bank
|
||||
* account token or rejects when the token creation fails
|
||||
*/
|
||||
export const createToken = data =>
|
||||
new Promise((resolve, reject) => {
|
||||
window.Stripe.bankAccount.createToken(data, (status, response) => {
|
||||
if (response.error) {
|
||||
const e = new Error(response.error.message);
|
||||
e.stripeError = response.error;
|
||||
reject(e);
|
||||
} else {
|
||||
resolve(response.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Country specific Stripe configurations
|
||||
*
|
||||
* @param {String} countryCode - string representing country code (e.g. 'US', 'FI')
|
||||
*
|
||||
* @return {Object} configurations
|
||||
*/
|
||||
export const stripeCountryConfigs = countryCode => {
|
||||
const country = config.stripe.supportedCountries.find(c => c.code === countryCode);
|
||||
|
||||
if (!country) {
|
||||
throw new Error(`Country code not found in Stripe config ${countryCode}`);
|
||||
}
|
||||
return country;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return all the inputs that are required in given country
|
||||
*
|
||||
* @param {String} countryCode - string representing country code (e.g. 'US', 'FI')
|
||||
*
|
||||
* @return {Array<String>} array containing different input 'types'
|
||||
* (e.g. ['routingNumber', 'accountNumber'])
|
||||
*/
|
||||
export const requiredInputs = countryCode => {
|
||||
const bankAccountInputs = stripeCountryConfigs(countryCode).accountConfig;
|
||||
return BANK_ACCOUNT_INPUTS.filter(inputType => bankAccountInputs[inputType]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate input type to human readable string
|
||||
*
|
||||
* @param {String} inputType - string representing one of the required bank account input
|
||||
* @param {Object} intl - translation library to format messages
|
||||
*
|
||||
* @return {String} formatted message
|
||||
*/
|
||||
export const inputTypeToString = (inputType, intl) => {
|
||||
if (BANK_ACCOUNT_INPUTS.includes(inputType)) {
|
||||
return intl.formatMessage({ id: `StripeBankAccountTokenInputField.${inputType}.inline` });
|
||||
} else {
|
||||
throw new Error(`Unknown inputType (${inputType}) given to validator`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate Stripe error
|
||||
*
|
||||
* @param {String} country - string representing country code (e.g. 'US', 'FI')
|
||||
* @param {Object} intl - translation library to format errors
|
||||
* @param {Object} stripeError - actual Stripe error, which functions as default message,
|
||||
* if no translation can be found
|
||||
*
|
||||
* @return {String} formatted Stripe error
|
||||
*/
|
||||
export const translateStripeError = (country, intl, stripeError) => {
|
||||
console.error('Stripe error:', stripeError); // eslint-disable-line no-console
|
||||
const inputs = requiredInputs(country);
|
||||
const ibanRequired = inputs[IBAN];
|
||||
if (ibanRequired) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'StripeBankAccountTokenInputField.genericStripeErrorIban',
|
||||
defaultMessage: stripeError.message,
|
||||
},
|
||||
{ country }
|
||||
);
|
||||
} else {
|
||||
const inputsAsStrings = inputs.map(inputType => inputTypeToString(inputType, intl));
|
||||
|
||||
const andTranslated = intl.formatMessage({
|
||||
id: 'StripeBankAccountTokenInputField.andBeforeLastItemInAList',
|
||||
});
|
||||
// Print required inputs (to be included to error message)
|
||||
// e.g. "bank code, branch code and account number"
|
||||
const inputsInString = inputsAsStrings.length > 1
|
||||
? inputsAsStrings.join(', ').replace(/,([^,]*)$/, `${andTranslated} $1`)
|
||||
: inputsAsStrings[0];
|
||||
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'StripeBankAccountTokenInputField.genericStripeError',
|
||||
defaultMessage: stripeError.message,
|
||||
},
|
||||
{ country, inputs: inputsInString }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate input before creating Token
|
||||
*
|
||||
* @param {String} inputType - input type (e.g. 'routingNumber', 'IBAN')
|
||||
* @param {String} value - input value
|
||||
* @param {String} country - string representing country code (e.g. 'US', 'FI')
|
||||
*
|
||||
* @return {Boolean} is valid value.
|
||||
*/
|
||||
export const validateInput = (inputType, value, country, stripe) => {
|
||||
switch (inputType) {
|
||||
case ACCOUNT_NUMBER:
|
||||
return stripe.bankAccount.validateAccountNumber(value, country);
|
||||
case ROUTING_NUMBER:
|
||||
return stripe.bankAccount.validateRoutingNumber(value, country);
|
||||
case BSB:
|
||||
case IBAN:
|
||||
case SORT_CODE:
|
||||
// Unfortunately we don't have validation for these yet
|
||||
// (Stripe errors work as validation)
|
||||
return true;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown inputType (${inputType}) given to validator`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map inputs to correct Stripe keys
|
||||
*
|
||||
* @param {String} inputType - input type (e.g. 'routingNumber', 'IBAN')
|
||||
* @param {String} value - input value
|
||||
*
|
||||
* @return {Object} key - value in Object literal.
|
||||
*/
|
||||
export const mapInputsToStripeAccountKeys = (inputType, value) => {
|
||||
// Stripe documentation speaks about actual bank account terms of different countries
|
||||
// (like BSB, sort code, routing number), but all of those get mapped to one of
|
||||
// the two different request keys: routing_number or account_number
|
||||
// See: https://stripe.com/docs/payouts vs https://stripe.com/docs/connect/testing
|
||||
|
||||
// We use those country specific terms since we want to show correct labels and errors for users,
|
||||
// so this mapping is needed before sending data to Stripe.
|
||||
|
||||
switch (inputType) {
|
||||
case IBAN:
|
||||
case ACCOUNT_NUMBER:
|
||||
return { account_number: value };
|
||||
case BSB:
|
||||
case SORT_CODE:
|
||||
case ROUTING_NUMBER:
|
||||
return { routing_number: value };
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown inputType (${inputType}) given to validator`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate messages related to different input types.
|
||||
*
|
||||
* Check translations for StripeBankAccountTokenInputField
|
||||
* from [rootFolder]/src/translations/en.json
|
||||
*
|
||||
* @param {Object} intl - translation library to format errors
|
||||
* @param {String} inputType - input type (e.g. 'routingNumber', 'IBAN')
|
||||
* @param {String} messageType - one of the different messages related to inputType
|
||||
* (e.g. 'inline', 'invalid', 'label', 'placeholder', 'required')
|
||||
*
|
||||
* @return {Object} key - value in Object literal.
|
||||
*/
|
||||
export const formatFieldMessage = (intl, inputType, messageType) => {
|
||||
if (!BANK_ACCOUNT_INPUTS.includes(inputType)) {
|
||||
throw new Error(`inputType (${inputType}) must be one of ${BANK_ACCOUNT_INPUTS}`);
|
||||
}
|
||||
|
||||
return intl.formatMessage({
|
||||
id: `StripeBankAccountTokenInputField.${inputType}.${messageType}`,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove all whitespace from the given string.
|
||||
*
|
||||
* @param {String} str - target string
|
||||
*
|
||||
* @return {String} cleaned string
|
||||
*/
|
||||
export const cleanedString = str => {
|
||||
return str ? str.replace(/\s/g, '') : '';
|
||||
};
|
||||
|
|
@ -46,8 +46,8 @@
|
|||
}
|
||||
|
||||
@media (--viewportMedium) {
|
||||
border-bottom-width: 3px;
|
||||
padding-bottom: 13px;
|
||||
border-bottom-width: 4px;
|
||||
padding: 16px 10px 14px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@
|
|||
transition: width var(--transitionStyleButton);
|
||||
}
|
||||
|
||||
.profileSettingsLink,
|
||||
.yourListingsLink {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -68,7 +68,19 @@ const TopbarDesktop = props => {
|
|||
<Avatar className={css.avatar} user={currentUser} />
|
||||
</MenuLabel>
|
||||
<MenuContent className={css.profileMenuContent}>
|
||||
<MenuItem key="manageListings">
|
||||
<MenuItem key="ProfileSettingsPage">
|
||||
<NamedLink
|
||||
className={classNames(
|
||||
css.profileSettingsLink,
|
||||
currentPageClass('ProfileSettingsPage')
|
||||
)}
|
||||
name="ProfileSettingsPage"
|
||||
>
|
||||
<span className={css.menuItemBorder} />
|
||||
<FormattedMessage id="TopbarDesktop.profileSettingsLink" />
|
||||
</NamedLink>
|
||||
</MenuItem>
|
||||
<MenuItem key="ManageListingsPage">
|
||||
<NamedLink
|
||||
className={classNames(css.yourListingsLink, currentPageClass('ManageListingsPage'))}
|
||||
name="ManageListingsPage"
|
||||
|
|
|
|||
|
|
@ -102,6 +102,22 @@ exports[`TopbarDesktop data matches snapshot 1`] = `
|
|||
style={Object {}}>
|
||||
<ul
|
||||
className="">
|
||||
<li
|
||||
className=""
|
||||
role="menuitem">
|
||||
<a
|
||||
className=""
|
||||
href="/profile-settings"
|
||||
onClick={[Function]}
|
||||
style={Object {}}
|
||||
title={null}>
|
||||
<span
|
||||
className={undefined} />
|
||||
<span>
|
||||
Profile settings
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className=""
|
||||
role="menuitem">
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ const TopbarMobileMenu = props => {
|
|||
>
|
||||
<FormattedMessage id="TopbarMobileMenu.yourListingsLink" />
|
||||
</NamedLink>
|
||||
<NamedLink
|
||||
className={classNames(css.navigationLink, currentPageClass('ProfileSettingsPage'))}
|
||||
name="ProfileSettingsPage"
|
||||
>
|
||||
<FormattedMessage id="TopbarMobileMenu.profileSettingsLink" />
|
||||
</NamedLink>
|
||||
|
||||
</div>
|
||||
<div className={css.footer}>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
@media (--viewportMedium) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: 55px;
|
||||
height: 57px;
|
||||
align-items: flex-end;
|
||||
padding: 13px 24px 0 24px;
|
||||
}
|
||||
|
|
@ -20,13 +20,8 @@
|
|||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
margin-left: 16px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (--viewportLarge) {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ const UserNav = props => {
|
|||
const tabs = [
|
||||
{
|
||||
text: <FormattedMessage id="ManageListingsPage.profileSettings" />,
|
||||
selected: selectedPageName === 'EditProfilePage',
|
||||
disabled: true,
|
||||
selected: selectedPageName === 'ProfileSettingsPage',
|
||||
disabled: false,
|
||||
linkProps: {
|
||||
name: 'EditProfilePage',
|
||||
params: { displayName: 'testinglink' },
|
||||
name: 'ProfileSettingsPage',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@ import ExternalLink from './ExternalLink/ExternalLink';
|
|||
import ExpandingTextarea from './ExpandingTextarea/ExpandingTextarea';
|
||||
import FilterPanel from './FilterPanel/FilterPanel';
|
||||
import HeroSection from './HeroSection/HeroSection';
|
||||
import IconEmailAttention from './IconEmailAttention/IconEmailAttention';
|
||||
import IconEmailSent from './IconEmailSent/IconEmailSent';
|
||||
import IconEmailSuccess from './IconEmailSuccess/IconEmailSuccess';
|
||||
import ImageCarousel from './ImageCarousel/ImageCarousel';
|
||||
import ImageFromFile from './ImageFromFile/ImageFromFile';
|
||||
import KeysIcon from './KeysIcon/KeysIcon';
|
||||
import KeysIconSuccess from './KeysIconSuccess/KeysIconSuccess';
|
||||
import ListingCard from './ListingCard/ListingCard';
|
||||
import LocationAutocompleteInput, {
|
||||
LocationAutocompleteInputField,
|
||||
|
|
@ -84,8 +90,14 @@ export {
|
|||
ExternalLink,
|
||||
FilterPanel,
|
||||
HeroSection,
|
||||
IconEmailAttention,
|
||||
IconEmailSent,
|
||||
IconEmailSuccess,
|
||||
ImageCarousel,
|
||||
ImageFromFile,
|
||||
InlineTextButton,
|
||||
KeysIcon,
|
||||
KeysIconSuccess,
|
||||
ListingCard,
|
||||
LocationAutocompleteInput,
|
||||
LocationAutocompleteInputField,
|
||||
|
|
|
|||
|
|
@ -35,82 +35,149 @@ const stripeSupportedCountries = [
|
|||
{
|
||||
// Australia
|
||||
code: 'AU',
|
||||
currency: 'AUD',
|
||||
payoutAddressRequired: false,
|
||||
accountConfig: {
|
||||
bsb: true,
|
||||
accountNumber: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Austria
|
||||
code: 'AT',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Belgium
|
||||
code: 'BE',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Denmark
|
||||
code: 'DK',
|
||||
currency: 'DKK',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Finland
|
||||
code: 'FI',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// France
|
||||
code: 'FR',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Germany
|
||||
code: 'DE',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Ireland
|
||||
code: 'IE',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Italy
|
||||
code: 'IT',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Luxembourg
|
||||
code: 'LU',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Netherlands
|
||||
code: 'NL',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Portugal
|
||||
code: 'PT',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Spain
|
||||
code: 'ES',
|
||||
currency: 'EUR',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Sweden
|
||||
code: 'SE',
|
||||
currency: 'SEK',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
iban: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// United Kingdom
|
||||
code: 'GB',
|
||||
currency: 'GBP',
|
||||
payoutAddressRequired: true,
|
||||
accountConfig: {
|
||||
sortCode: true,
|
||||
accountNumber: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// United States
|
||||
code: 'US',
|
||||
currency: 'USD',
|
||||
payoutAddressRequired: false,
|
||||
accountConfig: {
|
||||
routingNumber: true,
|
||||
accountNumber: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -75,11 +75,8 @@ const estimatedBreakdown = (bookingStart, bookingEnd, unitPrice) => {
|
|||
<BookingBreakdown
|
||||
className={css.receipt}
|
||||
userRole="customer"
|
||||
bookingStart={tx.booking.attributes.start}
|
||||
bookingEnd={tx.booking.attributes.end}
|
||||
transactionState={tx.attributes.state}
|
||||
payinTotal={tx.attributes.payinTotal}
|
||||
lineItems={tx.attributes.lineItems}
|
||||
transaction={tx}
|
||||
booking={tx.booking}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -97,6 +94,7 @@ export const BookingDatesFormComponent = props => {
|
|||
intl,
|
||||
startDatePlaceholder,
|
||||
endDatePlaceholder,
|
||||
isOwnListing,
|
||||
} = props;
|
||||
|
||||
const { startDate, endDate } = bookingDates;
|
||||
|
|
@ -172,7 +170,11 @@ export const BookingDatesFormComponent = props => {
|
|||
/>
|
||||
{bookingInfo}
|
||||
<p className={css.smallPrint}>
|
||||
<FormattedMessage id="BookingDatesForm.youWontBeChargedInfo" />
|
||||
<FormattedMessage
|
||||
id={
|
||||
isOwnListing ? 'BookingDatesForm.ownListing' : 'BookingDatesForm.youWontBeChargedInfo'
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
<PrimaryButton className={css.submitButton} type="submit" disabled={submitDisabled}>
|
||||
<FormattedMessage id="BookingDatesForm.requestToBook" />
|
||||
|
|
@ -185,11 +187,12 @@ BookingDatesFormComponent.defaultProps = {
|
|||
rootClassName: null,
|
||||
className: null,
|
||||
price: null,
|
||||
isOwnListing: false,
|
||||
startDatePlaceholder: null,
|
||||
endDatePlaceholder: null,
|
||||
};
|
||||
|
||||
const { instanceOf, shape, string } = PropTypes;
|
||||
const { instanceOf, shape, string, bool } = PropTypes;
|
||||
|
||||
BookingDatesFormComponent.propTypes = {
|
||||
...formPropTypes,
|
||||
|
|
@ -197,6 +200,7 @@ BookingDatesFormComponent.propTypes = {
|
|||
rootClassName: string,
|
||||
className: string,
|
||||
price: instanceOf(types.Money),
|
||||
isOwnListing: bool,
|
||||
|
||||
// from formValueSelector
|
||||
bookingDates: shape({
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import Decimal from 'decimal.js';
|
||||
import { types } from '../../util/sdkLoader';
|
||||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { fakeIntl, fakeFormProps } from '../../util/test-data';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { BookingBreakdown } from '../../components';
|
||||
import { BookingDatesFormComponent } from './BookingDatesForm';
|
||||
|
||||
const { Money } = types;
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
describe('BookingDatesForm', () => {
|
||||
|
|
@ -14,7 +20,7 @@ describe('BookingDatesForm', () => {
|
|||
intl={fakeIntl}
|
||||
dispatch={noop}
|
||||
onSubmit={v => v}
|
||||
price={new types.Money(1099, 'USD')}
|
||||
price={new Money(1099, 'USD')}
|
||||
bookingDates={{}}
|
||||
startDatePlaceholder="today"
|
||||
endDatePlaceholder="tomorrow"
|
||||
|
|
@ -23,21 +29,37 @@ describe('BookingDatesForm', () => {
|
|||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
it('matches snapshot with selected dates', () => {
|
||||
const tree = renderShallow(
|
||||
const price = new Money(1099, 'USD');
|
||||
const startDate = new Date(Date.UTC(2017, 3, 14));
|
||||
const endDate = new Date(Date.UTC(2017, 3, 16));
|
||||
const tree = shallow(
|
||||
<BookingDatesFormComponent
|
||||
{...fakeFormProps}
|
||||
intl={fakeIntl}
|
||||
dispatch={noop}
|
||||
onSubmit={v => v}
|
||||
price={new types.Money(1099, 'USD')}
|
||||
bookingDates={{
|
||||
startDate: new Date(Date.UTC(2017, 3, 14)),
|
||||
endDate: new Date(Date.UTC(2017, 3, 16)),
|
||||
}}
|
||||
price={price}
|
||||
bookingDates={{ startDate, endDate }}
|
||||
startDatePlaceholder="today"
|
||||
endDatePlaceholder="tomorrow"
|
||||
/>
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
const breakdown = tree.find(BookingBreakdown);
|
||||
const { userRole, transaction, booking } = breakdown.props();
|
||||
expect(userRole).toEqual('customer');
|
||||
expect(booking.attributes.start).toEqual(startDate);
|
||||
expect(booking.attributes.end).toEqual(endDate);
|
||||
expect(transaction.attributes.lastTransition).toEqual(propTypes.TX_TRANSITION_PREAUTHORIZE);
|
||||
expect(transaction.attributes.state).toEqual(propTypes.TX_STATE_PREAUTHORIZED);
|
||||
expect(transaction.attributes.payinTotal).toEqual(new Money(2198, 'USD'));
|
||||
expect(transaction.attributes.payoutTotal).toEqual(new Money(2198, 'USD'));
|
||||
expect(transaction.attributes.lineItems).toEqual([
|
||||
{
|
||||
code: 'line-item/night',
|
||||
unitPrice: price,
|
||||
quantity: new Decimal(2),
|
||||
lineTotal: new Money(2198, 'USD'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,72 +1,3 @@
|
|||
exports[`BookingDatesForm matches snapshot with selected dates 1`] = `
|
||||
<form
|
||||
className={null}
|
||||
onSubmit={[Function]}>
|
||||
<DateRangeInputField
|
||||
endDateId="fakeTestForm.bookingEndDate"
|
||||
endDateLabel="BookingDatesForm.bookingEndTitle"
|
||||
endDatePlaceholderText="tomorrow"
|
||||
format={null}
|
||||
name="bookingDates"
|
||||
startDateId="fakeTestForm.bookingStartDate"
|
||||
startDateLabel="BookingDatesForm.bookingStartTitle"
|
||||
startDatePlaceholderText="today"
|
||||
useMobileMargins={true}
|
||||
validate={
|
||||
Array [
|
||||
[Function],
|
||||
[Function],
|
||||
]
|
||||
} />
|
||||
<div>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="BookingDatesForm.priceBreakdownTitle"
|
||||
values={Object {}} />
|
||||
</h3>
|
||||
<BookingBreakdown
|
||||
bookingEnd={2017-04-16T00:00:00.000Z}
|
||||
bookingStart={2017-04-14T00:00:00.000Z}
|
||||
lineItems={
|
||||
Array [
|
||||
Object {
|
||||
"code": "line-item/night",
|
||||
"lineTotal": Money {
|
||||
"amount": 2198,
|
||||
"currency": "USD",
|
||||
},
|
||||
"quantity": "2",
|
||||
"unitPrice": Money {
|
||||
"amount": 1099,
|
||||
"currency": "USD",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
payinTotal={
|
||||
Money {
|
||||
"amount": 2198,
|
||||
"currency": "USD",
|
||||
}
|
||||
}
|
||||
transactionState="state/preauthorized"
|
||||
userRole="customer" />
|
||||
</div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="BookingDatesForm.youWontBeChargedInfo"
|
||||
values={Object {}} />
|
||||
</p>
|
||||
<PrimaryButton
|
||||
disabled={false}
|
||||
type="submit">
|
||||
<FormattedMessage
|
||||
id="BookingDatesForm.requestToBook"
|
||||
values={Object {}} />
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
`;
|
||||
|
||||
exports[`BookingDatesForm matches snapshot without selected dates 1`] = `
|
||||
<form
|
||||
className={null}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
/* eslint-disable no-console */
|
||||
import ChangePasswordForm from './ChangePasswordForm';
|
||||
|
||||
export const Empty = {
|
||||
component: ChangePasswordForm,
|
||||
props: {
|
||||
onSubmit(values) {
|
||||
console.log('submit new password form values:', values);
|
||||
},
|
||||
},
|
||||
group: 'forms',
|
||||
};
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Field, reduxForm, propTypes as formPropTypes } from 'redux-form';
|
||||
import { Button } from '../../components';
|
||||
|
||||
const ChangePasswordForm = props => {
|
||||
const { handleSubmit, pristine, submitting } = props;
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="newPassword1">New password</label>
|
||||
<Field name="newPassword1" component="input" type="password" />
|
||||
<label htmlFor="newPassword2">New password, again</label>
|
||||
<Field name="newPassword2" component="input" type="password" />
|
||||
<Button type="submit" disabled={pristine || submitting}>Change password</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
ChangePasswordForm.propTypes = { ...formPropTypes };
|
||||
|
||||
const defaultFormName = 'ChangePasswordForm';
|
||||
|
||||
export default reduxForm({ form: defaultFormName })(ChangePasswordForm);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import React from 'react';
|
||||
import { renderDeep } from '../../util/test-helpers';
|
||||
import ChangePasswordForm from './ChangePasswordForm';
|
||||
|
||||
describe('ChangePasswordForm', () => {
|
||||
it('matches snapshot', () => {
|
||||
const tree = renderDeep(<ChangePasswordForm />);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
exports[`ChangePasswordForm matches snapshot 1`] = `
|
||||
<form
|
||||
onSubmit={[Function]}>
|
||||
<label
|
||||
htmlFor="newPassword1">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
name="newPassword1"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onDragStart={[Function]}
|
||||
onDrop={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="password"
|
||||
value="" />
|
||||
<label
|
||||
htmlFor="newPassword2">
|
||||
New password, again
|
||||
</label>
|
||||
<input
|
||||
name="newPassword2"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onDragStart={[Function]}
|
||||
onDrop={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="password"
|
||||
value="" />
|
||||
<button
|
||||
className=""
|
||||
disabled={true}
|
||||
type="submit">
|
||||
Change password
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
|
|
@ -164,15 +164,14 @@ export class CheckoutPageComponent extends Component {
|
|||
return <NamedRedirect name="ListingPage" params={params} />;
|
||||
}
|
||||
|
||||
const breakdown = currentTransaction.id
|
||||
// Show breakdown only when transaction and booking are loaded
|
||||
// (i.e. have an id)
|
||||
const breakdown = currentTransaction.id && currentBooking.id
|
||||
? <BookingBreakdown
|
||||
className={css.bookingBreakdown}
|
||||
userRole="customer"
|
||||
transactionState={currentTransaction.attributes.state}
|
||||
bookingStart={currentBooking.attributes.start}
|
||||
bookingEnd={currentBooking.attributes.end}
|
||||
lineItems={currentTransaction.attributes.lineItems}
|
||||
payinTotal={currentTransaction.attributes.payinTotal}
|
||||
transaction={currentTransaction}
|
||||
booking={currentBooking}
|
||||
/>
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const noop = () => null;
|
|||
|
||||
describe('CheckoutPage', () => {
|
||||
it('matches snapshot', () => {
|
||||
const listing = createListing('listing1', createUser('author'));
|
||||
const listing = createListing('listing1', {}, { author: createUser('author') });
|
||||
const props = {
|
||||
bookingDates: {
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
|
|
@ -30,8 +30,13 @@ describe('CheckoutPage', () => {
|
|||
|
||||
describe('Duck', () => {
|
||||
it('ActionCreator: setInitialValues(initialValues)', () => {
|
||||
const author = createUser('author1');
|
||||
const listing = { ...createListing('00000000-0000-0000-0000-000000000000'), author };
|
||||
const listing = createListing(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
{},
|
||||
{
|
||||
author: createUser('author1'),
|
||||
}
|
||||
);
|
||||
const bookingDates = {
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
|
||||
|
|
@ -59,8 +64,13 @@ describe('CheckoutPage', () => {
|
|||
});
|
||||
|
||||
it('should handle SET_INITAL_VALUES', () => {
|
||||
const author = createUser('author1');
|
||||
const listing = { ...createListing('00000000-0000-0000-0000-000000000000'), author };
|
||||
const listing = createListing(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
{},
|
||||
{
|
||||
author: createUser('author1'),
|
||||
}
|
||||
);
|
||||
const bookingDates = {
|
||||
bookingStart: new Date(Date.UTC(2017, 3, 14)),
|
||||
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { logout, authenticationInProgress } from '../../ducks/Auth.duck';
|
||||
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { PageLayout, Topbar } from '../../components';
|
||||
|
||||
export const EditProfilePageComponent = props => {
|
||||
const {
|
||||
authInfoError,
|
||||
authInProgress,
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
history,
|
||||
isAuthenticated,
|
||||
location,
|
||||
logoutError,
|
||||
notificationCount,
|
||||
onLogout,
|
||||
onManageDisableScrolling,
|
||||
params,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
authInfoError={authInfoError}
|
||||
logoutError={logoutError}
|
||||
title={`Edit profile page with display name: ${params.displayName}`}
|
||||
>
|
||||
<Topbar
|
||||
authInProgress={authInProgress}
|
||||
currentUser={currentUser}
|
||||
currentUserHasListings={currentUserHasListings}
|
||||
history={history}
|
||||
isAuthenticated={isAuthenticated}
|
||||
location={location}
|
||||
notificationCount={notificationCount}
|
||||
onLogout={onLogout}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
EditProfilePageComponent.defaultProps = {
|
||||
authInfoError: null,
|
||||
currentUser: null,
|
||||
logoutError: null,
|
||||
notificationCount: 0,
|
||||
};
|
||||
|
||||
const { bool, func, instanceOf, number, object, shape, string } = PropTypes;
|
||||
|
||||
EditProfilePageComponent.propTypes = {
|
||||
authInfoError: instanceOf(Error),
|
||||
authInProgress: bool.isRequired,
|
||||
currentUser: propTypes.currentUser,
|
||||
currentUserHasListings: bool.isRequired,
|
||||
isAuthenticated: bool.isRequired,
|
||||
logoutError: instanceOf(Error),
|
||||
notificationCount: number,
|
||||
onLogout: func.isRequired,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
params: shape({ displayName: string.isRequired }).isRequired,
|
||||
|
||||
// from withRouter
|
||||
history: shape({
|
||||
push: func.isRequired,
|
||||
}).isRequired,
|
||||
location: shape({ state: object }).isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
// PageLayout needs authInfoError and logoutError, Topbar needs isAuthenticated
|
||||
const { authInfoError, isAuthenticated, logoutError } = state.Auth;
|
||||
// Topbar needs user info.
|
||||
const {
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
currentUserNotificationCount: notificationCount,
|
||||
} = state.user;
|
||||
return {
|
||||
authInfoError,
|
||||
authInProgress: authenticationInProgress(state),
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
currentUserNotificationCount: notificationCount,
|
||||
isAuthenticated,
|
||||
logoutError,
|
||||
scrollingDisabled: isScrollingDisabled(state),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onLogout: historyPush => dispatch(logout(historyPush)),
|
||||
onManageDisableScrolling: (componentId, disableScrolling) =>
|
||||
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
||||
});
|
||||
|
||||
const EditProfilePage = compose(connect(mapStateToProps, mapDispatchToProps), withRouter)(
|
||||
EditProfilePageComponent
|
||||
);
|
||||
|
||||
export default EditProfilePage;
|
||||
|
|
@ -10,10 +10,15 @@
|
|||
|
||||
.title {
|
||||
@apply --marketplaceModalTitle;
|
||||
margin: 28px 0 17px 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 28px 0 24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply --marketplaceButtonStyles;
|
||||
@apply --marketplaceButtonStylesPrimary;
|
||||
|
||||
margin-top: 24px;
|
||||
align-self: stretch;
|
||||
|
|
@ -24,6 +29,10 @@
|
|||
}
|
||||
|
||||
.error {
|
||||
margin-top: 24px;
|
||||
color: var(--failColor);
|
||||
margin: 23px 0 24px 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 28px 0 24px 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import React, { PropTypes } from 'react';
|
|||
import { compose } from 'redux';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { reduxForm, Field, propTypes as formPropTypes } from 'redux-form';
|
||||
import { Button, NamedLink } from '../../components';
|
||||
|
||||
import { PrimaryButton, NamedLink, IconEmailAttention, IconEmailSuccess } from '../../components';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
|
||||
import css from './EmailVerificationForm.css';
|
||||
|
||||
const EmailVerificationFormComponent = props => {
|
||||
const {
|
||||
currentUser,
|
||||
|
|
@ -16,6 +17,7 @@ const EmailVerificationFormComponent = props => {
|
|||
} = props;
|
||||
|
||||
const email = <strong>{currentUser.attributes.email}</strong>;
|
||||
const name = currentUser.attributes.profile.displayName;
|
||||
|
||||
const errorMessage = (
|
||||
<div className={css.error}>
|
||||
|
|
@ -26,6 +28,7 @@ const EmailVerificationFormComponent = props => {
|
|||
const verifyEmail = (
|
||||
<div className={css.root}>
|
||||
<div>
|
||||
<IconEmailAttention />
|
||||
<h1 className={css.title}>
|
||||
<FormattedMessage id="EmailVerificationForm.verifyEmailAddress" />
|
||||
</h1>
|
||||
|
|
@ -41,12 +44,12 @@ const EmailVerificationFormComponent = props => {
|
|||
<form onSubmit={handleSubmit}>
|
||||
<Field component="input" type="hidden" name="verificationToken" />
|
||||
|
||||
<Button className={css.button} type="submit" disabled={submitting || inProgress}>
|
||||
<PrimaryButton className={css.button} type="submit" disabled={submitting || inProgress}>
|
||||
|
||||
{submitting || inProgress
|
||||
? <FormattedMessage id="EmailVerificationForm.verifying" />
|
||||
: <FormattedMessage id="EmailVerificationForm.verify" />}
|
||||
</Button>
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -54,36 +57,33 @@ const EmailVerificationFormComponent = props => {
|
|||
const alreadyVerified = (
|
||||
<div className={css.root}>
|
||||
<div>
|
||||
<IconEmailSuccess />
|
||||
<h1 className={css.title}>
|
||||
<FormattedMessage id="EmailVerificationForm.emailVerified" />
|
||||
<FormattedMessage id="EmailVerificationForm.successTitle" values={{ name }} />
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="EmailVerificationForm.yourEmailAddressIsVerified"
|
||||
values={{ email }}
|
||||
/>
|
||||
<FormattedMessage id="EmailVerificationForm.successText" />
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<NamedLink className={css.button} name="LandingPage">
|
||||
<FormattedMessage id="EmailVerificationForm.okHomepage" />
|
||||
<FormattedMessage id="EmailVerificationForm.successButtonText" />
|
||||
</NamedLink>
|
||||
</div>
|
||||
);
|
||||
|
||||
return currentUser.attributes.emailVerified ? alreadyVerified : verifyEmail;
|
||||
};
|
||||
|
||||
EmailVerificationFormComponent.defaultProps = {
|
||||
currentUser: null,
|
||||
inProgress: false,
|
||||
verificationError: null,
|
||||
};
|
||||
const {
|
||||
instanceOf,
|
||||
bool,
|
||||
} = PropTypes;
|
||||
|
||||
const { instanceOf, bool } = PropTypes;
|
||||
|
||||
EmailVerificationFormComponent.propTypes = {
|
||||
...formPropTypes,
|
||||
|
|
@ -91,9 +91,11 @@ EmailVerificationFormComponent.propTypes = {
|
|||
currentUser: propTypes.currentUser.isRequired,
|
||||
verificationError: instanceOf(Error),
|
||||
};
|
||||
|
||||
const defaultFormName = 'EmailVerificationForm';
|
||||
|
||||
const EmailVerificationForm = compose(reduxForm({ form: defaultFormName }), injectIntl)(
|
||||
EmailVerificationFormComponent
|
||||
);
|
||||
|
||||
export default EmailVerificationForm;
|
||||
|
|
|
|||
|
|
@ -13,16 +13,14 @@ describe('InboxPage', () => {
|
|||
const flattenedRoutes = flattenRoutes(routesConfiguration);
|
||||
const provider = createUser('provider-user-id');
|
||||
const customer = createUser('customer-user-id');
|
||||
const booking1 = createBooking(
|
||||
'booking1',
|
||||
new Date(Date.UTC(2017, 1, 15)),
|
||||
new Date(Date.UTC(2017, 1, 16))
|
||||
);
|
||||
const booking2 = createBooking(
|
||||
'booking2',
|
||||
new Date(Date.UTC(2017, 2, 15)),
|
||||
new Date(Date.UTC(2017, 2, 16))
|
||||
);
|
||||
const booking1 = createBooking('booking1', {
|
||||
start: new Date(Date.UTC(2017, 1, 15)),
|
||||
end: new Date(Date.UTC(2017, 1, 16)),
|
||||
});
|
||||
const booking2 = createBooking('booking2', {
|
||||
start: new Date(Date.UTC(2017, 2, 15)),
|
||||
end: new Date(Date.UTC(2017, 2, 16)),
|
||||
});
|
||||
|
||||
const ordersProps = {
|
||||
location: { search: '' },
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ exports[`InboxPage matches snapshot 1`] = `
|
|||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2017-01-15T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
|
|
@ -174,6 +175,7 @@ exports[`InboxPage matches snapshot 1`] = `
|
|||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2016-01-15T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
|
|
@ -390,6 +392,7 @@ exports[`InboxPage matches snapshot 3`] = `
|
|||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2017-01-15T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
|
|
@ -476,6 +479,7 @@ exports[`InboxPage matches snapshot 3`] = `
|
|||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2016-01-15T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,15 @@
|
|||
padding-bottom: 66.6667%; /* 3:2 Aspect Ratio */
|
||||
background-color: var(--matterColorNegative); /* Loading BG color */
|
||||
|
||||
/* Image carousel can be opened from the image, therefore we should show a pointer */
|
||||
cursor: pointer;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
padding-bottom: 0; /* No fixed aspect on desktop layouts */
|
||||
}
|
||||
}
|
||||
|
||||
.ownListingActionBar {
|
||||
.actionBar {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -44,6 +47,12 @@
|
|||
color: var(--matterColorNegative);
|
||||
background-color: var(--matterColor);
|
||||
z-index: 1; /* bring on top of mobile image */
|
||||
|
||||
/* Action bar prevents the image click events going to the parent and
|
||||
should not show a pointer */
|
||||
cursor: initial;
|
||||
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.ownListingText {
|
||||
|
|
@ -51,12 +60,27 @@
|
|||
margin: 16px 12px 13px 24px;
|
||||
}
|
||||
|
||||
.closedListingText {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
margin: 16px 12px 13px 24px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editListingLink {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 16px 24px 13px 12px;
|
||||
color: var(--matterColorNegative);
|
||||
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
color: var(--matterColorLight);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -75,6 +99,8 @@
|
|||
right: 0;
|
||||
width: 100%;
|
||||
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
@media (--viewportMedium) {
|
||||
position: static;
|
||||
top: auto;
|
||||
|
|
@ -86,28 +112,37 @@
|
|||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.viewPhotos {
|
||||
@apply --marketplaceTinyFontStyles;
|
||||
font-weight: var(--fontWeightMedium);
|
||||
|
||||
/* Position and dimensions */
|
||||
position: absolute;
|
||||
bottom: 19px;
|
||||
right: 24px;
|
||||
margin: 0;
|
||||
padding: 6px 8px 6px 11px;
|
||||
padding: 6px 13px 8px 13px;
|
||||
|
||||
/* Colors */
|
||||
background-color: var(--matterColorLight);
|
||||
|
||||
/* Borders */
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-color: var(--matterColorAnti);
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
border-radius: var(--borderRadius);
|
||||
|
||||
cursor: pointer;
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--marketplaceColor);
|
||||
color: var(--matterColorLight);
|
||||
}
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 0;
|
||||
|
|
@ -133,6 +168,10 @@
|
|||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (--viewportLarge) {
|
||||
margin: 0 auto 117px;
|
||||
}
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
|
|
@ -471,3 +510,10 @@
|
|||
.bookButton {
|
||||
@apply --marketplaceButtonStylesPrimary;
|
||||
}
|
||||
|
||||
.closedListingButton {
|
||||
border-left: 1px solid var(--matterColorNegative);
|
||||
width: 100%;
|
||||
padding: 15px 24px 15px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
|
||||
import { compose } from 'redux';
|
||||
|
|
@ -51,6 +52,50 @@ const priceData = (price, intl) => {
|
|||
return {};
|
||||
};
|
||||
|
||||
export const ActionBar = props => {
|
||||
const {
|
||||
isOwnListing,
|
||||
isClosed,
|
||||
editParams,
|
||||
} = props;
|
||||
|
||||
if (isOwnListing) {
|
||||
return (
|
||||
<div className={css.actionBar}>
|
||||
<p className={css.ownListingText}>
|
||||
<FormattedMessage
|
||||
id={isClosed ? 'ListingPage.ownClosedListing' : 'ListingPage.ownListing'}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const { arrayOf, bool, func, instanceOf, number, object, oneOf, shape, string } = PropTypes;
|
||||
|
||||
ActionBar.propTypes = {
|
||||
isOwnListing: bool.isRequired,
|
||||
isClosed: bool.isRequired,
|
||||
editParams: object.isRequired,
|
||||
};
|
||||
|
||||
ActionBar.displayName = 'ActionBar';
|
||||
|
||||
// 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 {
|
||||
|
|
@ -136,7 +181,10 @@ export class ListingPageComponent extends Component {
|
|||
const hasImages = currentListing.images && currentListing.images.length > 0;
|
||||
const firstImage = hasImages ? currentListing.images[0] : null;
|
||||
|
||||
const handleViewPhotosClick = () => {
|
||||
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,
|
||||
});
|
||||
|
|
@ -151,7 +199,7 @@ export class ListingPageComponent extends Component {
|
|||
: null;
|
||||
|
||||
const authorAvailable = currentListing && currentListing.author;
|
||||
const userAndListingAuthorAvailable = currentUser && authorAvailable;
|
||||
const userAndListingAuthorAvailable = !!(currentUser && authorAvailable);
|
||||
const isOwnListing = userAndListingAuthorAvailable &&
|
||||
currentListing.author.id.uuid === currentUser.id.uuid;
|
||||
|
||||
|
|
@ -179,13 +227,20 @@ export class ListingPageComponent extends Component {
|
|||
</div>
|
||||
: null;
|
||||
|
||||
const showClosedListingHelpText = currentListing.id && !currentListing.attributes.open;
|
||||
const bookingHeading = (
|
||||
<div className={css.bookingHeading}>
|
||||
<h2 className={css.bookingTitle}>
|
||||
<FormattedMessage id="ListingPage.bookingTitle" values={{ title }} />
|
||||
</h2>
|
||||
<div className={css.bookingHelp}>
|
||||
<FormattedMessage id="ListingPage.bookingHelp" />
|
||||
<FormattedMessage
|
||||
id={
|
||||
showClosedListingHelpText
|
||||
? 'ListingPage.bookingHelpClosedListing'
|
||||
: 'ListingPage.bookingHelp'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -200,17 +255,6 @@ export class ListingPageComponent extends Component {
|
|||
};
|
||||
|
||||
const editParams = { ...params, type: 'edit', tab: 'description' };
|
||||
const ownListingActionBar = isOwnListing
|
||||
? <div className={css.ownListingActionBar}>
|
||||
<p className={css.ownListingText}>
|
||||
<FormattedMessage id="ListingPage.ownListing" />
|
||||
</p>
|
||||
<NamedLink className={css.editListingLink} name="EditListingPage" params={editParams}>
|
||||
<EditIcon className={css.editIcon} />
|
||||
<FormattedMessage id="ListingPage.editListing" />
|
||||
</NamedLink>
|
||||
</div>
|
||||
: null;
|
||||
|
||||
const listingClasses = classNames(css.pageRoot);
|
||||
|
||||
|
|
@ -223,6 +267,18 @@ 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()}>
|
||||
<ActionBar
|
||||
isOwnListing={isOwnListing}
|
||||
isClosed={!currentListing.attributes.open}
|
||||
editParams={editParams}
|
||||
/>
|
||||
</div>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
authInfoError={authInfoError}
|
||||
|
|
@ -243,8 +299,8 @@ export class ListingPageComponent extends Component {
|
|||
/>
|
||||
<div className={listingClasses}>
|
||||
<div className={css.threeToTwoWrapper}>
|
||||
<div className={css.aspectWrapper}>
|
||||
{ownListingActionBar}
|
||||
<div className={css.aspectWrapper} onClick={handleViewPhotosClick}>
|
||||
{actionBar}
|
||||
<ResponsiveImage
|
||||
rootClassName={css.rootForImage}
|
||||
alt={title}
|
||||
|
|
@ -330,11 +386,14 @@ export class ListingPageComponent extends Component {
|
|||
</div>
|
||||
|
||||
{bookingHeading}
|
||||
<BookingDatesForm
|
||||
className={css.bookingForm}
|
||||
onSubmit={handleBookingSubmit}
|
||||
price={price}
|
||||
/>
|
||||
{currentListing.attributes.open
|
||||
? <BookingDatesForm
|
||||
className={css.bookingForm}
|
||||
onSubmit={handleBookingSubmit}
|
||||
price={price}
|
||||
isOwnListing={isOwnListing}
|
||||
/>
|
||||
: null}
|
||||
</ModalInMobile>
|
||||
<div className={css.openBookingForm}>
|
||||
<div className={css.priceContainer}>
|
||||
|
|
@ -346,9 +405,13 @@ export class ListingPageComponent extends Component {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Button rootClassName={css.bookButton} onClick={handleBookButtonClick}>
|
||||
{bookBtnMessage}
|
||||
</Button>
|
||||
{currentListing.attributes.open
|
||||
? <Button rootClassName={css.bookButton} onClick={handleBookButtonClick}>
|
||||
{bookBtnMessage}
|
||||
</Button>
|
||||
: <div className={css.closedListingButton}>
|
||||
<FormattedMessage id="ListingPage.closedListingButtonText" />
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -366,8 +429,6 @@ ListingPageComponent.defaultProps = {
|
|||
tab: 'listing',
|
||||
};
|
||||
|
||||
const { arrayOf, bool, func, instanceOf, number, object, oneOf, shape, string } = PropTypes;
|
||||
|
||||
ListingPageComponent.propTypes = {
|
||||
// from withRouter
|
||||
history: shape({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { types } from '../../util/sdkLoader';
|
||||
import { createUser, createCurrentUser, createListing, fakeIntl } from '../../util/test-data';
|
||||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { ListingPageComponent } from './ListingPage';
|
||||
import { ListingPageComponent, ActionBar } from './ListingPage';
|
||||
import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck';
|
||||
import { showListingRequest, showListingError, showListing } from './ListingPage.duck';
|
||||
|
||||
|
|
@ -12,7 +14,7 @@ const noop = () => null;
|
|||
describe('ListingPage', () => {
|
||||
it('matches snapshot', () => {
|
||||
const currentUser = createCurrentUser('user-2');
|
||||
const listing1 = createListing('listing1', createUser('user-1'));
|
||||
const listing1 = createListing('listing1', {}, { author: createUser('user-1') });
|
||||
const getListing = () => listing1;
|
||||
const props = {
|
||||
flattenedRoutes: [],
|
||||
|
|
@ -82,4 +84,33 @@ describe('ListingPage', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActionBar', () => {
|
||||
it('shows users own listing status', () => {
|
||||
const actionBar = shallow(<ActionBar isOwnListing isClosed={false} editParams={{}} />);
|
||||
const formattedMessages = actionBar.find(FormattedMessage);
|
||||
expect(formattedMessages.length).toEqual(2);
|
||||
expect(formattedMessages.at(0).props().id).toEqual('ListingPage.ownListing');
|
||||
expect(formattedMessages.at(1).props().id).toEqual('ListingPage.editListing');
|
||||
});
|
||||
it('shows users own closed listing status', () => {
|
||||
const actionBar = shallow(<ActionBar isOwnListing isClosed editParams={{}} />);
|
||||
const formattedMessages = actionBar.find(FormattedMessage);
|
||||
expect(formattedMessages.length).toEqual(2);
|
||||
expect(formattedMessages.at(0).props().id).toEqual('ListingPage.ownClosedListing');
|
||||
expect(formattedMessages.at(1).props().id).toEqual('ListingPage.editListing');
|
||||
});
|
||||
it('shows closed listing status', () => {
|
||||
const actionBar = shallow(<ActionBar isOwnListing={false} isClosed editParams={{}} />);
|
||||
const formattedMessages = actionBar.find(FormattedMessage);
|
||||
expect(formattedMessages.length).toEqual(1);
|
||||
expect(formattedMessages.at(0).props().id).toEqual('ListingPage.closedListing');
|
||||
});
|
||||
it("is missing if listing is not closed or user's own", () => {
|
||||
const actionBar = shallow(
|
||||
<ActionBar isOwnListing={false} isClosed={false} editParams={{}} />
|
||||
);
|
||||
expect(actionBar.equals(null)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,7 +42,22 @@ exports[`ListingPage matches snapshot 1`] = `
|
|||
<div
|
||||
className="">
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
onClick={[Function]}>
|
||||
<div
|
||||
onClick={[Function]}>
|
||||
<ActionBar
|
||||
editParams={
|
||||
Object {
|
||||
"id": "listing1",
|
||||
"slug": "listing1-title",
|
||||
"tab": "description",
|
||||
"type": "edit",
|
||||
}
|
||||
}
|
||||
isClosed={false}
|
||||
isOwnListing={false} />
|
||||
</div>
|
||||
<ResponsiveImage
|
||||
alt="listing1 title"
|
||||
className={null}
|
||||
|
|
@ -193,6 +208,7 @@ exports[`ListingPage matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
<BookingDatesForm
|
||||
isOwnListing={false}
|
||||
onSubmit={[Function]}
|
||||
price={
|
||||
Money {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export class ManageListingsPageComponent extends Component {
|
|||
const {
|
||||
authInfoError,
|
||||
authInProgress,
|
||||
closingListing,
|
||||
closingListingError,
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
history,
|
||||
|
|
@ -47,15 +49,16 @@ export class ManageListingsPageComponent extends Component {
|
|||
logoutError,
|
||||
notificationCount,
|
||||
onCloseListing,
|
||||
onOpenListing,
|
||||
onLogout,
|
||||
onManageDisableScrolling,
|
||||
onOpenListing,
|
||||
openingListing,
|
||||
openingListingError,
|
||||
pagination,
|
||||
queryInProgress,
|
||||
queryListingsError,
|
||||
queryParams,
|
||||
openingListing,
|
||||
closingListing,
|
||||
scrollingDisabled,
|
||||
} = this.props;
|
||||
|
||||
// TODO Handle openingListingError, closingListingError,
|
||||
|
|
@ -101,9 +104,16 @@ export class ManageListingsPageComponent extends Component {
|
|||
: null;
|
||||
|
||||
const listingMenuOpen = this.state.listingMenuOpen;
|
||||
const closingErrorListingId = !!closingListingError && closingListingError.listingId;
|
||||
const openingErrorListingId = !!openingListingError && openingListingError.listingId;
|
||||
|
||||
return (
|
||||
<PageLayout authInfoError={authInfoError} logoutError={logoutError} title="Manage listings">
|
||||
<PageLayout
|
||||
authInfoError={authInfoError}
|
||||
logoutError={logoutError}
|
||||
scrollingDisabled={scrollingDisabled}
|
||||
title="Manage listings"
|
||||
>
|
||||
<Topbar
|
||||
authInProgress={authInProgress}
|
||||
currentUser={currentUser}
|
||||
|
|
@ -115,6 +125,7 @@ export class ManageListingsPageComponent extends Component {
|
|||
notificationCount={notificationCount}
|
||||
onLogout={onLogout}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
scrollingDisabled={scrollingDisabled}
|
||||
/>
|
||||
<UserNav selectedPageName="ManageListingsPage" />
|
||||
{queryInProgress ? loadingResults : null}
|
||||
|
|
@ -132,6 +143,8 @@ export class ManageListingsPageComponent extends Component {
|
|||
onToggleMenu={this.onToggleMenu}
|
||||
onCloseListing={onCloseListing}
|
||||
onOpenListing={onOpenListing}
|
||||
hasOpeningError={openingErrorListingId.uuid === l.id.uuid}
|
||||
hasClosingError={closingErrorListingId.uuid === l.id.uuid}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -153,7 +166,9 @@ ManageListingsPageComponent.defaultProps = {
|
|||
queryListingsError: null,
|
||||
queryParams: null,
|
||||
closingListing: null,
|
||||
closingListingError: null,
|
||||
openingListing: null,
|
||||
openingListingError: null,
|
||||
};
|
||||
|
||||
const { arrayOf, bool, func, instanceOf, number, object, shape, string } = PropTypes;
|
||||
|
|
@ -161,6 +176,11 @@ const { arrayOf, bool, func, instanceOf, number, object, shape, string } = PropT
|
|||
ManageListingsPageComponent.propTypes = {
|
||||
authInfoError: instanceOf(Error),
|
||||
authInProgress: bool.isRequired,
|
||||
closingListing: shape({ uuid: string.isRequired }),
|
||||
closingListingError: shape({
|
||||
listingId: propTypes.uuid.isRequired,
|
||||
error: instanceOf(Error).isRequired,
|
||||
}),
|
||||
currentUser: propTypes.currentUser,
|
||||
currentUserHasListings: bool.isRequired,
|
||||
isAuthenticated: bool.isRequired,
|
||||
|
|
@ -168,15 +188,19 @@ ManageListingsPageComponent.propTypes = {
|
|||
logoutError: instanceOf(Error),
|
||||
notificationCount: number,
|
||||
onCloseListing: func.isRequired,
|
||||
onOpenListing: func.isRequired,
|
||||
onLogout: func.isRequired,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
onOpenListing: func.isRequired,
|
||||
openingListing: shape({ uuid: string.isRequired }),
|
||||
openingListingError: shape({
|
||||
listingId: propTypes.uuid.isRequired,
|
||||
error: instanceOf(Error).isRequired,
|
||||
}),
|
||||
pagination: propTypes.pagination,
|
||||
queryInProgress: bool.isRequired,
|
||||
queryListingsError: instanceOf(Error),
|
||||
queryParams: object,
|
||||
closingListing: shape({ uuid: string.isRequired }),
|
||||
openingListing: shape({ uuid: string.isRequired }),
|
||||
scrollingDisabled: bool.isRequired,
|
||||
|
||||
// from withRouter
|
||||
history: shape({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
|
|||
<withRouter(PageLayout)
|
||||
authInfoError={null}
|
||||
logoutError={null}
|
||||
scrollingDisabled={false}
|
||||
title="Manage listings">
|
||||
<Topbar
|
||||
authInProgress={false}
|
||||
|
|
@ -21,7 +22,8 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
|
|||
}
|
||||
notificationCount={0}
|
||||
onLogout={[Function]}
|
||||
onManageDisableScrolling={[Function]} />
|
||||
onManageDisableScrolling={[Function]}
|
||||
scrollingDisabled={false} />
|
||||
<UserNav
|
||||
className={null}
|
||||
rootClassName={null}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,10 @@ describe('OrderPage', () => {
|
|||
const transaction = createTransaction({
|
||||
id: txId,
|
||||
state: 'state/preauthorized',
|
||||
booking: createBooking(
|
||||
'booking1',
|
||||
new Date(Date.UTC(2017, 5, 10)),
|
||||
new Date(Date.UTC(2017, 5, 13))
|
||||
),
|
||||
booking: createBooking('booking1', {
|
||||
start: new Date(Date.UTC(2017, 5, 10)),
|
||||
end: new Date(Date.UTC(2017, 5, 13)),
|
||||
}),
|
||||
listing: createListing('listing1'),
|
||||
customer: createUser('customer1'),
|
||||
provider: createUser('provider1'),
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ exports[`OrderPage matches snapshot 1`] = `
|
|||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2017-06-01T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
|
|
@ -123,7 +124,6 @@ exports[`OrderPage matches snapshot 1`] = `
|
|||
},
|
||||
"title": "listing1 title",
|
||||
},
|
||||
"author": null,
|
||||
"id": UUID {
|
||||
"uuid": "listing1",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { logout, authenticationInProgress } from '../../ducks/Auth.duck';
|
||||
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { PageLayout, Topbar } from '../../components';
|
||||
import { ChangePasswordForm } from '../../containers';
|
||||
|
||||
const changePassword = values => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('submit with values:', values);
|
||||
};
|
||||
|
||||
export const PasswordChangePageComponent = props => {
|
||||
const {
|
||||
authInfoError,
|
||||
authInProgress,
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
history,
|
||||
isAuthenticated,
|
||||
location,
|
||||
logoutError,
|
||||
notificationCount,
|
||||
onLogout,
|
||||
onManageDisableScrolling,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<PageLayout authInfoError={authInfoError} logoutError={logoutError} title="Type new password">
|
||||
<Topbar
|
||||
authInProgress={authInProgress}
|
||||
currentUser={currentUser}
|
||||
currentUserHasListings={currentUserHasListings}
|
||||
history={history}
|
||||
isAuthenticated={isAuthenticated}
|
||||
location={location}
|
||||
notificationCount={notificationCount}
|
||||
onLogout={onLogout}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
/>
|
||||
<ChangePasswordForm onSubmit={changePassword} />
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordChangePageComponent.defaultProps = {
|
||||
authInfoError: null,
|
||||
currentUser: null,
|
||||
logoutError: null,
|
||||
notificationCount: 0,
|
||||
};
|
||||
|
||||
const { bool, func, instanceOf, number, object, shape } = PropTypes;
|
||||
|
||||
PasswordChangePageComponent.propTypes = {
|
||||
authInfoError: instanceOf(Error),
|
||||
authInProgress: bool.isRequired,
|
||||
currentUser: propTypes.currentUser,
|
||||
currentUserHasListings: bool.isRequired,
|
||||
isAuthenticated: bool.isRequired,
|
||||
logoutError: instanceOf(Error),
|
||||
notificationCount: number,
|
||||
onLogout: func.isRequired,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
|
||||
// from withRouter
|
||||
history: shape({
|
||||
push: func.isRequired,
|
||||
}).isRequired,
|
||||
location: shape({ state: object }).isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
// PageLayout needs authInfoError and logoutError, Topbar needs isAuthenticated
|
||||
const { authInfoError, isAuthenticated, logoutError } = state.Auth;
|
||||
// Topbar needs user info.
|
||||
const {
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
currentUserNotificationCount: notificationCount,
|
||||
} = state.user;
|
||||
return {
|
||||
authInfoError,
|
||||
authInProgress: authenticationInProgress(state),
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
currentUserNotificationCount: notificationCount,
|
||||
isAuthenticated,
|
||||
logoutError,
|
||||
scrollingDisabled: isScrollingDisabled(state),
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onLogout: historyPush => dispatch(logout(historyPush)),
|
||||
onManageDisableScrolling: (componentId, disableScrolling) =>
|
||||
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
||||
});
|
||||
|
||||
const PasswordChangePage = compose(connect(mapStateToProps, mapDispatchToProps), withRouter)(
|
||||
PasswordChangePageComponent
|
||||
);
|
||||
|
||||
export default PasswordChangePage;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import React from 'react';
|
||||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { PasswordChangePageComponent } from './PasswordChangePage';
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
describe('ContactDetailsPage', () => {
|
||||
it('matches snapshot', () => {
|
||||
const tree = renderShallow(
|
||||
<PasswordChangePageComponent
|
||||
params={{ displayName: 'my-shop' }}
|
||||
history={{ push: noop }}
|
||||
location={{ search: '' }}
|
||||
scrollingDisabled={false}
|
||||
authInProgress={false}
|
||||
currentUserHasListings={false}
|
||||
isAuthenticated={false}
|
||||
onLogout={noop}
|
||||
onManageDisableScrolling={noop}
|
||||
/>
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
exports[`ContactDetailsPage matches snapshot 1`] = `
|
||||
<withRouter(PageLayout)
|
||||
authInfoError={null}
|
||||
logoutError={null}
|
||||
title="Type new password">
|
||||
<Topbar
|
||||
authInProgress={false}
|
||||
currentUser={null}
|
||||
currentUserHasListings={false}
|
||||
history={
|
||||
Object {
|
||||
"push": [Function],
|
||||
}
|
||||
}
|
||||
isAuthenticated={false}
|
||||
location={
|
||||
Object {
|
||||
"search": "",
|
||||
}
|
||||
}
|
||||
notificationCount={0}
|
||||
onLogout={[Function]}
|
||||
onManageDisableScrolling={[Function]} />
|
||||
<ReduxForm
|
||||
onSubmit={[Function]} />
|
||||
</withRouter(PageLayout)>
|
||||
`;
|
||||
15
src/containers/PasswordResetForm/PasswordResetForm.css
Normal file
15
src/containers/PasswordResetForm/PasswordResetForm.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.root {
|
||||
|
||||
}
|
||||
|
||||
.passwordInput {
|
||||
/* Leave space between the input and the button below when the
|
||||
viewport height is small */
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
/* In a flexbox context, this makes the button position itself to the
|
||||
bottom */
|
||||
margin-top: auto;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/* eslint-disable no-console */
|
||||
import PasswordResetForm from './PasswordResetForm';
|
||||
|
||||
export const Empty = {
|
||||
component: PasswordResetForm,
|
||||
props: {
|
||||
onSubmit(values) {
|
||||
console.log('submit with values:', values);
|
||||
},
|
||||
},
|
||||
group: 'forms',
|
||||
};
|
||||
89
src/containers/PasswordResetForm/PasswordResetForm.js
Normal file
89
src/containers/PasswordResetForm/PasswordResetForm.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { compose } from 'redux';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
|
||||
import classNames from 'classnames';
|
||||
import { PrimaryButton, TextInputField } from '../../components';
|
||||
import * as validators from '../../util/validators';
|
||||
|
||||
import css from './PasswordResetForm.css';
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
const PasswordResetFormComponent = props => {
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
form,
|
||||
handleSubmit,
|
||||
submitting,
|
||||
inProgress,
|
||||
intl,
|
||||
invalid,
|
||||
} = props;
|
||||
|
||||
// password
|
||||
const passwordLabel = intl.formatMessage({
|
||||
id: 'PasswordResetForm.passwordLabel',
|
||||
});
|
||||
const passwordPlaceholder = intl.formatMessage({
|
||||
id: 'PasswordResetForm.passwordPlaceholder',
|
||||
});
|
||||
const passwordRequiredMessage = intl.formatMessage({
|
||||
id: 'PasswordResetForm.passwordRequired',
|
||||
});
|
||||
const passwordMinLengthMessage = intl.formatMessage(
|
||||
{
|
||||
id: 'PasswordResetForm.passwordTooShort',
|
||||
},
|
||||
{
|
||||
minLength: PASSWORD_MIN_LENGTH,
|
||||
}
|
||||
);
|
||||
const passwordRequired = validators.required(passwordRequiredMessage);
|
||||
const passwordMinLength = validators.minLength(passwordMinLengthMessage, PASSWORD_MIN_LENGTH);
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const submitDisabled = invalid || submitting || inProgress;
|
||||
|
||||
return (
|
||||
<form className={classes} onSubmit={handleSubmit}>
|
||||
<TextInputField
|
||||
className={css.passwordInput}
|
||||
type="password"
|
||||
name="password"
|
||||
id={`${form}.password`}
|
||||
label={passwordLabel}
|
||||
placeholder={passwordPlaceholder}
|
||||
validate={[passwordRequired, passwordMinLength]}
|
||||
/>
|
||||
<PrimaryButton className={css.submitButton} type="submit" disabled={submitDisabled}>
|
||||
<FormattedMessage id="PasswordResetForm.submitButtonText" />
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordResetFormComponent.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
inProgress: false,
|
||||
};
|
||||
|
||||
const { string, bool } = PropTypes;
|
||||
|
||||
PasswordResetFormComponent.propTypes = {
|
||||
...formPropTypes,
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
inProgress: bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const defaultFormName = 'PasswordResetForm';
|
||||
|
||||
const PasswordResetForm = compose(reduxForm({ form: defaultFormName }), injectIntl)(
|
||||
PasswordResetFormComponent
|
||||
);
|
||||
|
||||
export default PasswordResetForm;
|
||||
70
src/containers/PasswordResetPage/PasswordResetPage.css
Normal file
70
src/containers/PasswordResetPage/PasswordResetPage.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
@apply --backgroundImage;
|
||||
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 30px 24px 98px 24px;
|
||||
background-color: var(--matterColorLight);
|
||||
border-radius: 2px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
flex-basis: 480px;
|
||||
flex-grow: 0;
|
||||
min-height: 573px;
|
||||
padding: 60px;
|
||||
margin-top: 7.5vh;
|
||||
margin-bottom: 7.5vh;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--failColor);
|
||||
}
|
||||
|
||||
.mainHeading {
|
||||
margin-top: 26px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
.helpText {
|
||||
margin-top: 12px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
flex: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 37px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonLink {
|
||||
@apply --marketplaceButtonStylesPrimary;
|
||||
|
||||
/* Pull the button to the bottom of the flex container */
|
||||
margin-top: auto;
|
||||
}
|
||||
51
src/containers/PasswordResetPage/PasswordResetPage.duck.js
Normal file
51
src/containers/PasswordResetPage/PasswordResetPage.duck.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// ================ Action types ================ //
|
||||
|
||||
export const RESET_PASSWORD_REQUEST = 'app/PasswordResetPage/RESET_PASSWORD_REQUEST';
|
||||
export const RESET_PASSWORD_SUCCESS = 'app/PasswordResetPage/RESET_PASSWORD_SUCCESS';
|
||||
export const RESET_PASSWORD_ERROR = 'app/PasswordResetPage/RESET_PASSWORD_ERROR';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const initialState = {
|
||||
resetPasswordInProgress: false,
|
||||
resetPasswordError: null,
|
||||
};
|
||||
|
||||
export default function reducer(state = initialState, action = {}) {
|
||||
const { type, payload } = action;
|
||||
switch (type) {
|
||||
case RESET_PASSWORD_REQUEST:
|
||||
return { ...state, resetPasswordInProgress: true, resetPasswordError: null };
|
||||
case RESET_PASSWORD_SUCCESS:
|
||||
return { ...state, resetPasswordInProgress: false };
|
||||
case RESET_PASSWORD_ERROR:
|
||||
console.error(payload); // eslint-disable-line no-console
|
||||
return { ...state, resetPasswordInProgress: false, resetPasswordError: payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ================ Action creators ================ //
|
||||
|
||||
export const resetPasswordRequest = () => ({ type: RESET_PASSWORD_REQUEST });
|
||||
|
||||
export const resetPasswordSuccess = () => ({ type: RESET_PASSWORD_SUCCESS });
|
||||
|
||||
export const resetPasswordError = e => ({
|
||||
type: RESET_PASSWORD_ERROR,
|
||||
error: true,
|
||||
payload: e,
|
||||
});
|
||||
|
||||
// ================ Thunks ================ //
|
||||
|
||||
export const resetPassword = (email, passwordResetToken, newPassword) =>
|
||||
(dispatch, getState, sdk) => {
|
||||
dispatch(resetPasswordRequest());
|
||||
const params = { email, passwordResetToken, newPassword };
|
||||
return sdk.passwordReset
|
||||
.reset(params)
|
||||
.then(() => dispatch(resetPasswordSuccess()))
|
||||
.catch(e => dispatch(resetPasswordError(e)));
|
||||
};
|
||||
218
src/containers/PasswordResetPage/PasswordResetPage.js
Normal file
218
src/containers/PasswordResetPage/PasswordResetPage.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import React, { PropTypes, Component } from 'react';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { PageLayout, Topbar, NamedLink, KeysIcon, KeysIconSuccess } from '../../components';
|
||||
import { PasswordResetForm } from '../../containers';
|
||||
import { logout, authenticationInProgress } from '../../ducks/Auth.duck';
|
||||
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { parse } from '../../util/urlHelpers';
|
||||
import { resetPassword } from './PasswordResetPage.duck';
|
||||
|
||||
import css from './PasswordResetPage.css';
|
||||
|
||||
const { bool, func, instanceOf, number, object, shape, string } = PropTypes;
|
||||
|
||||
const parseUrlParams = location => {
|
||||
const params = parse(location.search);
|
||||
const { t: token, e: email } = params;
|
||||
return { token, email };
|
||||
};
|
||||
|
||||
export class PasswordResetPageComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { newPasswordSubmitted: false };
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
authInfoError,
|
||||
authInProgress,
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
intl,
|
||||
isAuthenticated,
|
||||
logoutError,
|
||||
notificationCount,
|
||||
onLogout,
|
||||
onManageDisableScrolling,
|
||||
scrollingDisabled,
|
||||
location,
|
||||
history,
|
||||
resetPasswordInProgress,
|
||||
resetPasswordError,
|
||||
onSubmitPassword,
|
||||
} = this.props;
|
||||
|
||||
const title = intl.formatMessage({
|
||||
id: 'PasswordResetPage.title',
|
||||
});
|
||||
|
||||
const { token, email } = parseUrlParams(location);
|
||||
const paramsValid = !!(token && email);
|
||||
|
||||
const handleSubmit = values => {
|
||||
const { password } = values;
|
||||
this.setState({ newPasswordSubmitted: false });
|
||||
onSubmitPassword(email, token, password).then(() => {
|
||||
this.setState({ newPasswordSubmitted: true });
|
||||
});
|
||||
};
|
||||
|
||||
const paramsErrorContent = (
|
||||
<div className={css.content}>
|
||||
<p className={css.error}>
|
||||
<FormattedMessage id="PasswordResetPage.invalidUrlParams" />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const resetFormContent = (
|
||||
<div className={css.content}>
|
||||
<KeysIcon />
|
||||
<h1 className={css.mainHeading}>
|
||||
<FormattedMessage id="PasswordResetPage.mainHeading" />
|
||||
</h1>
|
||||
<p className={css.helpText}>
|
||||
<FormattedMessage id="PasswordResetPage.helpText" />
|
||||
</p>
|
||||
{resetPasswordError
|
||||
? <p className={css.error}>
|
||||
<FormattedMessage id="PasswordResetPage.resetFailed" />
|
||||
</p>
|
||||
: null}
|
||||
<PasswordResetForm
|
||||
className={css.form}
|
||||
onSubmit={handleSubmit}
|
||||
inProgress={resetPasswordInProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const resetDoneContent = (
|
||||
<div className={css.content}>
|
||||
<KeysIconSuccess />
|
||||
<h1 className={css.mainHeading}>
|
||||
<FormattedMessage id="PasswordResetPage.passwordChangedHeading" />
|
||||
</h1>
|
||||
<p className={css.helpText}>
|
||||
<FormattedMessage id="PasswordResetPage.passwordChangedHelpText" />
|
||||
</p>
|
||||
<NamedLink name="LoginPage" className={css.buttonLink}>
|
||||
<FormattedMessage id="PasswordResetPage.loginButtonText" />
|
||||
</NamedLink>
|
||||
</div>
|
||||
);
|
||||
|
||||
let content;
|
||||
|
||||
if (!paramsValid) {
|
||||
content = paramsErrorContent;
|
||||
} else if (!resetPasswordError && this.state.newPasswordSubmitted) {
|
||||
content = resetDoneContent;
|
||||
} else {
|
||||
content = resetFormContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title={title}
|
||||
authInfoError={authInfoError}
|
||||
logoutError={logoutError}
|
||||
scrollingDisabled={scrollingDisabled}
|
||||
>
|
||||
<Topbar
|
||||
isAuthenticated={isAuthenticated}
|
||||
authInProgress={authInProgress}
|
||||
currentUser={currentUser}
|
||||
currentUserHasListings={currentUserHasListings}
|
||||
notificationCount={notificationCount}
|
||||
history={history}
|
||||
location={location}
|
||||
onLogout={onLogout}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
/>
|
||||
<div className={css.root}>
|
||||
{content}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PasswordResetPageComponent.defaultProps = {
|
||||
authInfoError: null,
|
||||
currentUser: null,
|
||||
logoutError: null,
|
||||
notificationCount: 0,
|
||||
resetPasswordError: null,
|
||||
};
|
||||
|
||||
PasswordResetPageComponent.propTypes = {
|
||||
authInfoError: instanceOf(Error),
|
||||
authInProgress: bool.isRequired,
|
||||
currentUser: propTypes.currentUser,
|
||||
currentUserHasListings: bool.isRequired,
|
||||
isAuthenticated: bool.isRequired,
|
||||
logoutError: instanceOf(Error),
|
||||
notificationCount: number,
|
||||
onLogout: func.isRequired,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
scrollingDisabled: bool.isRequired,
|
||||
|
||||
resetPasswordInProgress: bool.isRequired,
|
||||
resetPasswordError: instanceOf(Error),
|
||||
onSubmitPassword: func.isRequired,
|
||||
|
||||
// from withRouter
|
||||
history: object.isRequired,
|
||||
location: shape({
|
||||
search: string,
|
||||
}).isRequired,
|
||||
|
||||
// from injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const {
|
||||
authInfoError,
|
||||
isAuthenticated,
|
||||
logoutError,
|
||||
} = state.Auth;
|
||||
const {
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
currentUserNotificationCount: notificationCount,
|
||||
} = state.user;
|
||||
const { resetPasswordInProgress, resetPasswordError } = state.PasswordResetPage;
|
||||
return {
|
||||
authInfoError,
|
||||
authInProgress: authenticationInProgress(state),
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
isAuthenticated,
|
||||
logoutError,
|
||||
notificationCount,
|
||||
scrollingDisabled: isScrollingDisabled(state),
|
||||
resetPasswordInProgress,
|
||||
resetPasswordError,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onLogout: () => dispatch(logout()),
|
||||
onManageDisableScrolling: (componentId, disableScrolling) =>
|
||||
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
||||
onSubmitPassword: (email, token, password) => dispatch(resetPassword(email, token, password)),
|
||||
});
|
||||
|
||||
const PasswordResetPage = compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withRouter,
|
||||
injectIntl
|
||||
)(PasswordResetPageComponent);
|
||||
|
||||
export default PasswordResetPage;
|
||||
|
|
@ -18,14 +18,25 @@ import css from './PayoutDetailsForm.css';
|
|||
|
||||
const supportedCountries = config.stripe.supportedCountries.map(c => c.code);
|
||||
|
||||
const requiresAddress = countryCode => {
|
||||
export const stripeCountryConfigs = countryCode => {
|
||||
const country = config.stripe.supportedCountries.find(c => c.code === countryCode);
|
||||
|
||||
if (!country) {
|
||||
throw new Error(`Country code not found in Stripe config ${countryCode}`);
|
||||
}
|
||||
return country;
|
||||
};
|
||||
|
||||
const requiresAddress = countryCode => {
|
||||
const country = stripeCountryConfigs(countryCode);
|
||||
return country.payoutAddressRequired;
|
||||
};
|
||||
|
||||
const countryCurrency = countryCode => {
|
||||
const country = stripeCountryConfigs(countryCode);
|
||||
return country.currency;
|
||||
};
|
||||
|
||||
const PayoutDetailsFormComponent = props => {
|
||||
const {
|
||||
className,
|
||||
|
|
@ -151,10 +162,9 @@ const PayoutDetailsFormComponent = props => {
|
|||
</h3>
|
||||
<StripeBankAccountTokenInputField
|
||||
name="bankAccountToken"
|
||||
routingNumberId={`${form}.bankAccountToken.routingNumber`}
|
||||
accountNumberId={`${form}.bankAccountToken.accountNumber`}
|
||||
formName={form}
|
||||
country={country}
|
||||
currency={config.currency}
|
||||
currency={countryCurrency(country)}
|
||||
validate={bankAccountRequired}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
278
src/containers/ProfileSettingsForm/ProfileSettingsForm.css
Normal file
278
src/containers/ProfileSettingsForm/ProfileSettingsForm.css
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
:root {
|
||||
--avatarSize: 96px;
|
||||
--avatarSizeDesktop: 240px;
|
||||
}
|
||||
|
||||
.root {
|
||||
margin-top: 23px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionContainer {
|
||||
padding: 0;
|
||||
margin-bottom: 36px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
padding: 0;
|
||||
margin-bottom: 54px;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
/* Font */
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
margin-top: 0;
|
||||
margin-bottom: 13px;
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.lastSection {
|
||||
margin-bottom: 66px;
|
||||
@media (--viewportMedium) {
|
||||
margin-bottom: 111px;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadAvatarInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.uploadAvatarWrapper {
|
||||
margin-top: 19px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 44px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
width: var(--avatarSize);
|
||||
@media (--viewportMedium) {
|
||||
width: var(--avatarSizeDesktop);
|
||||
}
|
||||
}
|
||||
|
||||
.avatarPlaceholder,
|
||||
.avatarContainer {
|
||||
/* Dimension */
|
||||
position: relative;
|
||||
width: var(--avatarSize);
|
||||
height: var(--avatarSize);
|
||||
|
||||
/* Center content */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
/* Initial coloring */
|
||||
background-color: var(--matterColorBright);
|
||||
border-radius: calc(var(--avatarSize) / 2);
|
||||
cursor: pointer;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
width: var(--avatarSizeDesktop);
|
||||
height: var(--avatarSizeDesktop);
|
||||
border-radius: calc(var(--avatarSizeDesktop) / 2);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.avatarPlaceholder {
|
||||
/* Placeholder border */
|
||||
border-style: dashed;
|
||||
border-color: var(--matterColorNegative);
|
||||
border-width: 2px;
|
||||
|
||||
transition: var(--transitionStyleButton);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--matterColorAnti);
|
||||
}
|
||||
}
|
||||
|
||||
.avatarPlaceholderTextMobile {
|
||||
@media (--viewportMedium) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.avatarPlaceholderText {
|
||||
display: none;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
display: block;
|
||||
max-width: 130px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.avatarUploadError {
|
||||
/* Placeholder border */
|
||||
border-style: dashed;
|
||||
border-color: var(--failColor);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.error {
|
||||
/* Font */
|
||||
@apply --marketplaceH4FontStyles;
|
||||
color: var(--failColor);
|
||||
margin-top: 18px;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.changeAvatar {
|
||||
/* Font */
|
||||
@apply --marketplaceH5FontStyles;
|
||||
|
||||
font-weight: var(--fontWeightMedium);
|
||||
|
||||
/* Positioning: right */
|
||||
position: absolute;
|
||||
bottom: 27px;
|
||||
right: -129px;
|
||||
/* Dimensions */
|
||||
width: 105px;
|
||||
height: 41px;
|
||||
padding: 10px 10px 11px 35px;
|
||||
|
||||
/* Look and feel (buttonish) */
|
||||
background-color: var(--matterColorLight);
|
||||
background-image: url('data:image/svg+xml;utf8,<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g stroke="#4A4A4A" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"><path d="M5.307 11.155L1 13l1.846-4.308L10.54 1 13 3.46zM11 5L9 3M5 11L3 9"/></g></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: 15px 12px;
|
||||
border: solid 1px var(--matterColorNegative);
|
||||
border-radius: 2px;
|
||||
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
/* Position: under */
|
||||
bottom: -10px;
|
||||
right: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
transition: var(--transitionStyleButton);
|
||||
padding: 7px 10px 11px 35px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border: solid 1px var(--matterColorAnti);
|
||||
}
|
||||
}
|
||||
|
||||
.uploadingImage {
|
||||
/* Dimensions */
|
||||
width: var(--avatarSize);
|
||||
height: var(--avatarSize);
|
||||
|
||||
/* Image fitted to container */
|
||||
object-fit: cover;
|
||||
background-color: var(--matterColorNegative); /* Loading BG color */
|
||||
border-radius: calc(var(--avatarSize) / 2);
|
||||
overflow: hidden;
|
||||
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
width: var(--avatarSizeDesktop);
|
||||
height: var(--avatarSizeDesktop);
|
||||
border-radius: calc(var(--avatarSizeDesktop) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
.uploadingImageOverlay {
|
||||
/* Cover everything (overlay) */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
/* Overlay style */
|
||||
background-color: var(--matterColorLight);
|
||||
opacity: 0.8;
|
||||
|
||||
/* Center content */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Avatar has square aspect ratio */
|
||||
/* Default is 3:2 */
|
||||
.squareAspectRatio {
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
|
||||
.avatarInvisible {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
@apply --marketplaceDefaultFontStyles;
|
||||
color: var(--matterColorAnti);
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
color: var(--matterColorAnti);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nameContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.firstName {
|
||||
width: calc(34% - 9px);
|
||||
}
|
||||
|
||||
.lastName {
|
||||
width: calc(66% - 9px);
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
margin-top: 24px;
|
||||
}
|
||||
271
src/containers/ProfileSettingsForm/ProfileSettingsForm.js
Normal file
271
src/containers/ProfileSettingsForm/ProfileSettingsForm.js
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { compose } from 'redux';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
import { Field, reduxForm, propTypes as formPropTypes } from 'redux-form';
|
||||
import classNames from 'classnames';
|
||||
import { ensureCurrentUser } from '../../util/data';
|
||||
import * as validators from '../../util/validators';
|
||||
import { Avatar, Button, ImageFromFile, SpinnerIcon, TextInputField } from '../../components';
|
||||
|
||||
import css from './ProfileSettingsForm.css';
|
||||
|
||||
const ACCEPT_IMAGES = 'image/*';
|
||||
const UPLOAD_CHANGE_DELAY = 2000; // Show spinner so that browser has time to load img srcset
|
||||
|
||||
const RenderAvatar = props => {
|
||||
const { accept, id, input, label, type, disabled, uploadImageError } = props;
|
||||
const { name, onChange } = input;
|
||||
const error = uploadImageError
|
||||
? <div className={css.error}>
|
||||
<FormattedMessage id="ProfileSettingsForm.imageUploadFailed" />
|
||||
</div>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={css.uploadAvatarWrapper}>
|
||||
<label className={css.label} htmlFor={id}>{label}</label>
|
||||
<input
|
||||
accept={accept}
|
||||
className={css.uploadAvatarInput}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
name={name}
|
||||
onChange={event => {
|
||||
const file = event.target.files[0];
|
||||
const tempId = `${file.name}_${Date.now()}`;
|
||||
onChange({ id: tempId, file });
|
||||
}}
|
||||
type={type}
|
||||
/>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RenderAvatar.defaultProps = { uploadImageError: null };
|
||||
const { bool, func, instanceOf, node, object, shape, string } = PropTypes;
|
||||
|
||||
RenderAvatar.propTypes = {
|
||||
accept: string.isRequired,
|
||||
disabled: bool.isRequired,
|
||||
id: string.isRequired,
|
||||
input: shape({
|
||||
value: object,
|
||||
onChange: func.isRequired,
|
||||
name: string.isRequired,
|
||||
}).isRequired,
|
||||
label: node.isRequired,
|
||||
type: string.isRequired,
|
||||
uploadImageError: instanceOf(Error),
|
||||
};
|
||||
|
||||
class ProfileSettingsFormComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.uploadDelayTimeoutId = null;
|
||||
this.state = { uploadDelay: false };
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// Upload delay is additional time window where Avatar is added to the DOM,
|
||||
// but not yet visible (time to load image URL from srcset)
|
||||
if (this.props.uploadInProgress && !nextProps.uploadInProgress) {
|
||||
this.setState({ uploadDelay: true });
|
||||
this.uploadDelayTimeoutId = window.setTimeout(
|
||||
() => {
|
||||
this.setState({ uploadDelay: false });
|
||||
},
|
||||
UPLOAD_CHANGE_DELAY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.clearTimeout(this.blurTimeoutId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
currentUser,
|
||||
form,
|
||||
handleSubmit,
|
||||
intl,
|
||||
invalid,
|
||||
onImageUpload,
|
||||
pristine,
|
||||
profileImage,
|
||||
rootClassName,
|
||||
submitting,
|
||||
updateInProgress,
|
||||
updateProfileError,
|
||||
uploadImageError,
|
||||
uploadInProgress,
|
||||
} = this.props;
|
||||
|
||||
const user = ensureCurrentUser(currentUser);
|
||||
|
||||
// First name
|
||||
const firstNameLabel = intl.formatMessage({
|
||||
id: 'ProfileSettingsForm.firstNameLabel',
|
||||
});
|
||||
const firstNamePlaceholder = intl.formatMessage({
|
||||
id: 'ProfileSettingsForm.firstNamePlaceholder',
|
||||
});
|
||||
const firstNameRequiredMessage = intl.formatMessage({
|
||||
id: 'ProfileSettingsForm.firstNameRequired',
|
||||
});
|
||||
const firstNameRequired = validators.required(firstNameRequiredMessage);
|
||||
|
||||
// Last name
|
||||
const lastNameLabel = intl.formatMessage({
|
||||
id: 'ProfileSettingsForm.lastNameLabel',
|
||||
});
|
||||
const lastNamePlaceholder = intl.formatMessage({
|
||||
id: 'ProfileSettingsForm.lastNamePlaceholder',
|
||||
});
|
||||
const lastNameRequiredMessage = intl.formatMessage({
|
||||
id: 'ProfileSettingsForm.lastNameRequired',
|
||||
});
|
||||
const lastNameRequired = validators.required(lastNameRequiredMessage);
|
||||
|
||||
const uploadingOverlay = uploadInProgress || this.state.uploadDelay
|
||||
? <div className={css.uploadingImageOverlay}><SpinnerIcon /></div>
|
||||
: null;
|
||||
|
||||
const hasUploadError = !!uploadImageError && !uploadInProgress;
|
||||
const errorClasses = classNames({ [css.avatarUploadError]: hasUploadError });
|
||||
const transientUserProfileImage = profileImage.uploadedImage || user.profileImage;
|
||||
const transientUser = { ...user, profileImage: transientUserProfileImage };
|
||||
|
||||
const fileUploadInProgress = uploadInProgress && profileImage.file;
|
||||
const delayAfterUpload = profileImage.imageId && this.state.uploadDelay;
|
||||
const imageFromFile = fileUploadInProgress || delayAfterUpload
|
||||
? <ImageFromFile
|
||||
id={profileImage.id}
|
||||
className={errorClasses}
|
||||
rootClassName={css.uploadingImage}
|
||||
aspectRatioClassName={css.squareAspectRatio}
|
||||
file={profileImage.file}
|
||||
>
|
||||
{uploadingOverlay}
|
||||
</ImageFromFile>
|
||||
: null;
|
||||
|
||||
// Avatar is rendered in hidden during the upload delay
|
||||
// Upload delay smoothes image change process:
|
||||
// responsive img has time to load srcset stuff before it is shown to user.
|
||||
const avatarClasses = classNames(errorClasses, {
|
||||
[css.avatarInvisible]: this.state.uploadDelay,
|
||||
});
|
||||
const avatarComponent = !fileUploadInProgress && profileImage.imageId
|
||||
? <Avatar className={avatarClasses} user={transientUser} />
|
||||
: null;
|
||||
|
||||
const chooseAvatarLabel = profileImage.imageId || fileUploadInProgress
|
||||
? <div className={css.avatarContainer}>
|
||||
{imageFromFile}
|
||||
{avatarComponent}
|
||||
<div className={css.changeAvatar}>
|
||||
<FormattedMessage id="ProfileSettingsForm.changeAvatar" />
|
||||
</div>
|
||||
</div>
|
||||
: <div className={css.avatarPlaceholder}>
|
||||
<div className={css.avatarPlaceholderText}>
|
||||
<FormattedMessage id="ProfileSettingsForm.addYourProfilePicture" />
|
||||
</div>
|
||||
<div className={css.avatarPlaceholderTextMobile}>
|
||||
<FormattedMessage id="ProfileSettingsForm.addYourProfilePictureMobile" />
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const submitError = updateProfileError
|
||||
? <div className={css.error}>
|
||||
<FormattedMessage id="ProfileSettingsForm.updateProfileFailed" />
|
||||
</div>
|
||||
: null;
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const inProgress = uploadInProgress || updateInProgress;
|
||||
const submitDisabled = invalid || submitting || inProgress || pristine;
|
||||
|
||||
return (
|
||||
<form className={classes} onSubmit={handleSubmit}>
|
||||
<div className={css.sectionContainer}>
|
||||
<h3 className={css.sectionTitle}>
|
||||
<FormattedMessage id="ProfileSettingsForm.yourProfilePicture" />
|
||||
</h3>
|
||||
<Field
|
||||
accept={ACCEPT_IMAGES}
|
||||
component={RenderAvatar}
|
||||
disabled={uploadInProgress}
|
||||
id="ProfileSettingsForm.changeAvatar"
|
||||
label={chooseAvatarLabel}
|
||||
name="profileImage"
|
||||
onChange={onImageUpload}
|
||||
type="file"
|
||||
uploadImageError={uploadImageError}
|
||||
/>
|
||||
<div className={css.tip}><FormattedMessage id="ProfileSettingsForm.tip" /></div>
|
||||
<div className={css.fileInfo}><FormattedMessage id="ProfileSettingsForm.fileInfo" /></div>
|
||||
</div>
|
||||
<div className={classNames(css.sectionContainer, css.lastSection)}>
|
||||
<h3 className={css.sectionTitle}>
|
||||
<FormattedMessage id="ProfileSettingsForm.yourName" />
|
||||
</h3>
|
||||
<div className={css.nameContainer}>
|
||||
<TextInputField
|
||||
className={css.firstName}
|
||||
type="text"
|
||||
name="firstName"
|
||||
id={`${form}.firstName`}
|
||||
label={firstNameLabel}
|
||||
placeholder={firstNamePlaceholder}
|
||||
validate={firstNameRequired}
|
||||
/>
|
||||
<TextInputField
|
||||
className={css.lastName}
|
||||
type="text"
|
||||
name="lastName"
|
||||
id={`${form}.lastName`}
|
||||
label={lastNameLabel}
|
||||
placeholder={lastNamePlaceholder}
|
||||
validate={lastNameRequired}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{submitError}
|
||||
<Button className={css.submitButton} type="submit" disabled={submitDisabled}>
|
||||
<FormattedMessage id="ProfileSettingsForm.saveChanges" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ProfileSettingsFormComponent.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
uploadImageError: null,
|
||||
updateProfileError: null,
|
||||
};
|
||||
|
||||
ProfileSettingsFormComponent.propTypes = {
|
||||
...formPropTypes,
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
uploadImageError: instanceOf(Error),
|
||||
uploadInProgress: bool.isRequired,
|
||||
updateInProgress: bool.isRequired,
|
||||
updateProfileError: instanceOf(Error),
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const defaultFormName = 'ProfileSettingsForm';
|
||||
|
||||
const ProfileSettingsForm = compose(reduxForm({ form: defaultFormName }), injectIntl)(
|
||||
ProfileSettingsFormComponent
|
||||
);
|
||||
|
||||
export default ProfileSettingsForm;
|
||||
14
src/containers/ProfileSettingsPage/ProfileSettingsPage.css
Normal file
14
src/containers/ProfileSettingsPage/ProfileSettingsPage.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
background-color: var(--matterColorLight);
|
||||
}
|
||||
.content {
|
||||
width: calc(100% - 48px);
|
||||
margin: 12px 24px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
max-width: 565px;
|
||||
margin: 56px auto;
|
||||
}
|
||||
}
|
||||
148
src/containers/ProfileSettingsPage/ProfileSettingsPage.duck.js
Normal file
148
src/containers/ProfileSettingsPage/ProfileSettingsPage.duck.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { updatedEntities, denormalisedEntities } from '../../util/data';
|
||||
import { currentUserShowSuccess } from '../../ducks/user.duck';
|
||||
|
||||
// ================ Action types ================ //
|
||||
|
||||
export const CLEAR_UPDATED_FORM = 'app/ProfileSettingsPage/CLEAR_UPDATED_FORM';
|
||||
|
||||
export const UPLOAD_IMAGE_REQUEST = 'app/ProfileSettingsPage/UPLOAD_IMAGE_REQUEST';
|
||||
export const UPLOAD_IMAGE_SUCCESS = 'app/ProfileSettingsPage/UPLOAD_IMAGE_SUCCESS';
|
||||
export const UPLOAD_IMAGE_ERROR = 'app/ProfileSettingsPage/UPLOAD_IMAGE_ERROR';
|
||||
|
||||
export const UPDATE_PROFILE_REQUEST = 'app/ProfileSettingsPage/UPDATE_PROFILE_REQUEST';
|
||||
export const UPDATE_PROFILE_SUCCESS = 'app/ProfileSettingsPage/UPDATE_PROFILE_SUCCESS';
|
||||
export const UPDATE_PROFILE_ERROR = 'app/ProfileSettingsPage/UPDATE_PROFILE_ERROR';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const initialState = {
|
||||
image: null,
|
||||
uploadImageError: null,
|
||||
uploadInProgress: false,
|
||||
updateInProgress: false,
|
||||
updateProfileError: null,
|
||||
};
|
||||
|
||||
export default function reducer(state = initialState, action = {}) {
|
||||
const { type, payload } = action;
|
||||
switch (type) {
|
||||
case UPLOAD_IMAGE_REQUEST:
|
||||
// payload.params: { id: 'tempId', file }
|
||||
return {
|
||||
...state,
|
||||
image: { ...payload.params },
|
||||
uploadInProgress: true,
|
||||
uploadImageError: null,
|
||||
};
|
||||
case UPLOAD_IMAGE_SUCCESS: {
|
||||
// payload: { id: 'tempId', uploadedImage }
|
||||
const { id, uploadedImage } = payload;
|
||||
const { file } = state.image || {};
|
||||
const image = { id, imageId: uploadedImage.id, file, uploadedImage };
|
||||
return { ...state, image, uploadInProgress: false };
|
||||
}
|
||||
case UPLOAD_IMAGE_ERROR: {
|
||||
// eslint-disable-next-line no-console
|
||||
return { ...state, image: null, uploadInProgress: false, uploadImageError: payload.error };
|
||||
}
|
||||
|
||||
case UPDATE_PROFILE_REQUEST:
|
||||
return {
|
||||
...state,
|
||||
updateInProgress: true,
|
||||
updateProfileError: null,
|
||||
};
|
||||
case UPDATE_PROFILE_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
image: null,
|
||||
updateInProgress: false,
|
||||
};
|
||||
case UPDATE_PROFILE_ERROR:
|
||||
return {
|
||||
...state,
|
||||
image: null,
|
||||
updateInProgress: false,
|
||||
updateProfileError: payload,
|
||||
};
|
||||
|
||||
case CLEAR_UPDATED_FORM:
|
||||
return { ...state, updateProfileError: null, uploadImageError: null };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ================ Selectors ================ //
|
||||
|
||||
// ================ Action creators ================ //
|
||||
|
||||
export const clearUpdatedForm = () => ({
|
||||
type: CLEAR_UPDATED_FORM,
|
||||
});
|
||||
|
||||
// SDK method: images.uploadListingImage
|
||||
export const uploadImageRequest = params => ({ type: UPLOAD_IMAGE_REQUEST, payload: { params } });
|
||||
export const uploadImageSuccess = result => ({ type: UPLOAD_IMAGE_SUCCESS, payload: result.data });
|
||||
export const uploadImageError = error => ({
|
||||
type: UPLOAD_IMAGE_ERROR,
|
||||
payload: error,
|
||||
error: true,
|
||||
});
|
||||
|
||||
// SDK method: sdk.currentUser.updateProfile
|
||||
export const updateProfileRequest = params => ({
|
||||
type: UPDATE_PROFILE_REQUEST,
|
||||
payload: { params },
|
||||
});
|
||||
export const updateProfileSuccess = result => ({
|
||||
type: UPDATE_PROFILE_SUCCESS,
|
||||
payload: result.data,
|
||||
});
|
||||
export const updateProfileError = error => ({
|
||||
type: UPDATE_PROFILE_ERROR,
|
||||
payload: error,
|
||||
error: true,
|
||||
});
|
||||
|
||||
// ================ Thunk ================ //
|
||||
|
||||
// Images return imageId which we need to map with previously generated temporary id
|
||||
export function uploadImage(actionPayload) {
|
||||
return (dispatch, getState, sdk) => {
|
||||
const id = actionPayload.id;
|
||||
dispatch(uploadImageRequest(actionPayload));
|
||||
|
||||
return sdk.images
|
||||
.uploadProfileImage({ image: actionPayload.file }, { expand: true })
|
||||
.then(resp => {
|
||||
const uploadedImage = resp.data.data;
|
||||
dispatch(uploadImageSuccess({ data: { id, uploadedImage } }));
|
||||
})
|
||||
.catch(e => dispatch(uploadImageError({ id, error: e })));
|
||||
};
|
||||
}
|
||||
|
||||
export const updateProfile = actionPayload => {
|
||||
return (dispatch, getState, sdk) => {
|
||||
dispatch(updateProfileRequest());
|
||||
|
||||
return sdk.currentUser
|
||||
.updateProfile(actionPayload, { expand: true, include: ['profileImage'] })
|
||||
.then(response => {
|
||||
dispatch(updateProfileSuccess(response));
|
||||
|
||||
// Temporary denormalization for profileImage
|
||||
// Profile image will be included to users
|
||||
const currentUserId = response.data.data.id;
|
||||
const entities = updatedEntities({}, response.data);
|
||||
const denormalised = denormalisedEntities(entities, 'current-user', [currentUserId]);
|
||||
const currentUser = denormalised[0];
|
||||
|
||||
// Update current user in state.user.currentUser through user.duck.js
|
||||
dispatch(currentUserShowSuccess(currentUser));
|
||||
})
|
||||
.catch(e => dispatch(updateProfileError(e)));
|
||||
};
|
||||
};
|
||||
201
src/containers/ProfileSettingsPage/ProfileSettingsPage.js
Normal file
201
src/containers/ProfileSettingsPage/ProfileSettingsPage.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { ensureCurrentUser } from '../../util/data';
|
||||
import { logout, authenticationInProgress } from '../../ducks/Auth.duck';
|
||||
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { clearUpdatedForm, updateProfile, uploadImage } from './ProfileSettingsPage.duck';
|
||||
import { PageLayout, Topbar, UserNav } from '../../components';
|
||||
import { ProfileSettingsForm } from '../../containers';
|
||||
|
||||
import css from './ProfileSettingsPage.css';
|
||||
|
||||
const onImageUploadHandler = (values, fn) => {
|
||||
const { id, imageId, file } = values;
|
||||
if (file) {
|
||||
fn({ id, imageId, file });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (values, fn) => {
|
||||
const { firstName, lastName, profileImage } = values;
|
||||
const name = { firstName, lastName };
|
||||
|
||||
// Update profileImage only if file system has been accessed
|
||||
const updatedValues = profileImage.imageId && profileImage.file
|
||||
? { ...name, profileImageId: profileImage.imageId }
|
||||
: name;
|
||||
|
||||
fn(updatedValues);
|
||||
};
|
||||
|
||||
export const ProfileSettingsPageComponent = props => {
|
||||
const {
|
||||
authInfoError,
|
||||
authInProgress,
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
history,
|
||||
image,
|
||||
isAuthenticated,
|
||||
location,
|
||||
logoutError,
|
||||
notificationCount,
|
||||
onChange,
|
||||
onImageUpload,
|
||||
onLogout,
|
||||
onManageDisableScrolling,
|
||||
onUpdateProfile,
|
||||
scrollingDisabled,
|
||||
updateInProgress,
|
||||
updateProfileError,
|
||||
uploadImageError,
|
||||
uploadInProgress,
|
||||
} = props;
|
||||
|
||||
const user = ensureCurrentUser(currentUser);
|
||||
const { firstName, lastName } = user.attributes.profile;
|
||||
const profileImage = image || { imageId: user.profileImage.id };
|
||||
|
||||
const profileSettingsForm = user.id
|
||||
? <ProfileSettingsForm
|
||||
className={css.form}
|
||||
currentUser={currentUser}
|
||||
initialValues={{ firstName, lastName, profileImage }}
|
||||
profileImage={profileImage}
|
||||
onImageUpload={e => onImageUploadHandler(e, onImageUpload)}
|
||||
uploadInProgress={uploadInProgress}
|
||||
updateInProgress={updateInProgress}
|
||||
uploadImageError={uploadImageError}
|
||||
updateProfileError={updateProfileError}
|
||||
onSubmit={values => {
|
||||
onSubmit({ ...values, profileImage }, onUpdateProfile);
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
className={css.root}
|
||||
authInfoError={authInfoError}
|
||||
logoutError={logoutError}
|
||||
title="Profile settings"
|
||||
scrollingDisabled={scrollingDisabled}
|
||||
>
|
||||
<Topbar
|
||||
authInProgress={authInProgress}
|
||||
currentPage="ProfileSettingsPage"
|
||||
currentUser={currentUser}
|
||||
currentUserHasListings={currentUserHasListings}
|
||||
history={history}
|
||||
isAuthenticated={isAuthenticated}
|
||||
location={location}
|
||||
notificationCount={notificationCount}
|
||||
onLogout={onLogout}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
/>
|
||||
<UserNav selectedPageName="ProfileSettingsPage" />
|
||||
|
||||
<div className={css.content}>
|
||||
<h1><FormattedMessage id="ProfileSettingsPage.title" /></h1>
|
||||
{profileSettingsForm}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileSettingsPageComponent.defaultProps = {
|
||||
authInfoError: null,
|
||||
currentUser: null,
|
||||
logoutError: null,
|
||||
notificationCount: 0,
|
||||
uploadImageError: null,
|
||||
updateProfileError: null,
|
||||
image: null,
|
||||
};
|
||||
|
||||
const { bool, func, instanceOf, number, object, shape, string } = PropTypes;
|
||||
|
||||
ProfileSettingsPageComponent.propTypes = {
|
||||
authInfoError: instanceOf(Error),
|
||||
authInProgress: bool.isRequired,
|
||||
currentUser: propTypes.currentUser,
|
||||
currentUserHasListings: bool.isRequired,
|
||||
isAuthenticated: bool.isRequired,
|
||||
image: shape({
|
||||
id: string,
|
||||
imageId: propTypes.uuid,
|
||||
file: object,
|
||||
uploadedImage: propTypes.image,
|
||||
}),
|
||||
logoutError: instanceOf(Error),
|
||||
notificationCount: number,
|
||||
onChange: func.isRequired,
|
||||
onImageUpload: func.isRequired,
|
||||
onLogout: func.isRequired,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
onUpdateProfile: func.isRequired,
|
||||
scrollingDisabled: bool.isRequired,
|
||||
updateInProgress: bool.isRequired,
|
||||
updateProfileError: instanceOf(Error),
|
||||
uploadImageError: instanceOf(Error),
|
||||
uploadInProgress: bool.isRequired,
|
||||
|
||||
// from withRouter
|
||||
history: shape({
|
||||
push: func.isRequired,
|
||||
}).isRequired,
|
||||
location: shape({ state: object }).isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
// PageLayout needs authInfoError and logoutError, Topbar needs isAuthenticated
|
||||
const { authInfoError, isAuthenticated, logoutError } = state.Auth;
|
||||
// Topbar needs user info.
|
||||
const {
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
currentUserNotificationCount: notificationCount,
|
||||
} = state.user;
|
||||
const {
|
||||
image,
|
||||
uploadImageError,
|
||||
uploadInProgress,
|
||||
updateInProgress,
|
||||
updateProfileError,
|
||||
} = state.ProfileSettingsPage;
|
||||
return {
|
||||
authInfoError,
|
||||
authInProgress: authenticationInProgress(state),
|
||||
currentUser,
|
||||
currentUserHasListings,
|
||||
currentUserNotificationCount: notificationCount,
|
||||
image,
|
||||
isAuthenticated,
|
||||
logoutError,
|
||||
scrollingDisabled: isScrollingDisabled(state),
|
||||
updateInProgress,
|
||||
updateProfileError,
|
||||
uploadImageError,
|
||||
uploadInProgress,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChange: () => dispatch(clearUpdatedForm()),
|
||||
onImageUpload: data => dispatch(uploadImage(data)),
|
||||
onLogout: historyPush => dispatch(logout(historyPush)),
|
||||
onManageDisableScrolling: (componentId, disableScrolling) =>
|
||||
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
||||
onUpdateProfile: data => dispatch(updateProfile(data)),
|
||||
});
|
||||
|
||||
const ProfileSettingsPage = compose(connect(mapStateToProps, mapDispatchToProps), withRouter)(
|
||||
ProfileSettingsPageComponent
|
||||
);
|
||||
|
||||
export default ProfileSettingsPage;
|
||||
|
|
@ -1,22 +1,27 @@
|
|||
import React from 'react';
|
||||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { EditProfilePageComponent } from './EditProfilePage';
|
||||
import { ProfileSettingsPageComponent } from './ProfileSettingsPage';
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
describe('ContactDetailsPage', () => {
|
||||
it('matches snapshot', () => {
|
||||
const tree = renderShallow(
|
||||
<EditProfilePageComponent
|
||||
params={{ displayName: 'my-shop' }}
|
||||
history={{ push: noop }}
|
||||
location={{ search: '' }}
|
||||
scrollingDisabled={false}
|
||||
<ProfileSettingsPageComponent
|
||||
authInProgress={false}
|
||||
currentUserHasListings={false}
|
||||
history={{ push: noop }}
|
||||
isAuthenticated={false}
|
||||
location={{ search: '' }}
|
||||
onChange={noop}
|
||||
onImageUpload={noop}
|
||||
onLogout={noop}
|
||||
onManageDisableScrolling={noop}
|
||||
onUpdateProfile={noop}
|
||||
params={{ displayName: 'my-shop' }}
|
||||
scrollingDisabled={false}
|
||||
updateInProgress={false}
|
||||
uploadInProgress={false}
|
||||
/>
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
|
@ -2,9 +2,11 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
|
|||
<withRouter(PageLayout)
|
||||
authInfoError={null}
|
||||
logoutError={null}
|
||||
title="Edit profile page with display name: my-shop">
|
||||
scrollingDisabled={false}
|
||||
title="Profile settings">
|
||||
<Topbar
|
||||
authInProgress={false}
|
||||
currentPage="ProfileSettingsPage"
|
||||
currentUser={null}
|
||||
currentUserHasListings={false}
|
||||
history={
|
||||
|
|
@ -21,5 +23,16 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
|
|||
notificationCount={0}
|
||||
onLogout={[Function]}
|
||||
onManageDisableScrolling={[Function]} />
|
||||
<UserNav
|
||||
className={null}
|
||||
rootClassName={null}
|
||||
selectedPageName="ProfileSettingsPage" />
|
||||
<div>
|
||||
<h1>
|
||||
<FormattedMessage
|
||||
id="ProfileSettingsPage.title"
|
||||
values={Object {}} />
|
||||
</h1>
|
||||
</div>
|
||||
</withRouter(PageLayout)>
|
||||
`;
|
||||
|
|
@ -18,11 +18,10 @@ describe('SalePage', () => {
|
|||
const transaction = createTransaction({
|
||||
id: txId,
|
||||
state: 'state/preauthorized',
|
||||
booking: createBooking(
|
||||
'booking1',
|
||||
new Date(Date.UTC(2017, 5, 10)),
|
||||
new Date(Date.UTC(2017, 5, 13))
|
||||
),
|
||||
booking: createBooking('booking1', {
|
||||
start: new Date(Date.UTC(2017, 5, 10)),
|
||||
end: new Date(Date.UTC(2017, 5, 13)),
|
||||
}),
|
||||
listing: createListing('listing1'),
|
||||
customer: createUser('customer1'),
|
||||
provider: createUser('provider1'),
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ exports[`SalePage matches snapshot 1`] = `
|
|||
Object {
|
||||
"attributes": Object {
|
||||
"createdAt": 2017-05-01T00:00:00.000Z,
|
||||
"lastTransition": "transition/preauthorize",
|
||||
"lastTransitionedAt": 2017-06-01T00:00:00.000Z,
|
||||
"lineItems": Array [
|
||||
Object {
|
||||
|
|
@ -127,7 +128,6 @@ exports[`SalePage matches snapshot 1`] = `
|
|||
},
|
||||
"title": "listing1 title",
|
||||
},
|
||||
"author": null,
|
||||
"id": UUID {
|
||||
"uuid": "listing1",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import AuthenticationPage from './AuthenticationPage/AuthenticationPage';
|
||||
import BookingDatesForm from './BookingDatesForm/BookingDatesForm';
|
||||
import ChangeAccountPasswordForm from './ChangeAccountPasswordForm/ChangeAccountPasswordForm';
|
||||
import ChangePasswordForm from './ChangePasswordForm/ChangePasswordForm';
|
||||
import CheckoutPage from './CheckoutPage/CheckoutPage';
|
||||
import ContactDetailsPage from './ContactDetailsPage/ContactDetailsPage';
|
||||
import EditListingDescriptionForm from './EditListingDescriptionForm/EditListingDescriptionForm';
|
||||
|
|
@ -9,9 +8,8 @@ import EditListingLocationForm from './EditListingLocationForm/EditListingLocati
|
|||
import EditListingPage from './EditListingPage/EditListingPage';
|
||||
import EditListingPhotosForm from './EditListingPhotosForm/EditListingPhotosForm';
|
||||
import EditListingPricingForm from './EditListingPricingForm/EditListingPricingForm';
|
||||
import EditProfilePage from './EditProfilePage/EditProfilePage';
|
||||
import EmailVerificationPage from './EmailVerificationPage/EmailVerificationPage';
|
||||
import EmailVerificationForm from './EmailVerificationForm/EmailVerificationForm';
|
||||
import EmailVerificationPage from './EmailVerificationPage/EmailVerificationPage';
|
||||
import InboxPage from './InboxPage/InboxPage';
|
||||
import LandingPage from './LandingPage/LandingPage';
|
||||
import ListingPage from './ListingPage/ListingPage';
|
||||
|
|
@ -20,12 +18,15 @@ import LoginForm from './LoginForm/LoginForm';
|
|||
import ManageListingsPage from './ManageListingsPage/ManageListingsPage';
|
||||
import NotFoundPage from './NotFoundPage/NotFoundPage';
|
||||
import OrderPage from './OrderPage/OrderPage';
|
||||
import PasswordChangePage from './PasswordChangePage/PasswordChangePage';
|
||||
import PasswordForgottenForm from './PasswordForgottenForm/PasswordForgottenForm';
|
||||
import PasswordForgottenPage from './PasswordForgottenPage/PasswordForgottenPage';
|
||||
import PasswordResetForm from './PasswordResetForm/PasswordResetForm';
|
||||
import PasswordResetPage from './PasswordResetPage/PasswordResetPage';
|
||||
import PayoutDetailsForm from './PayoutDetailsForm/PayoutDetailsForm';
|
||||
import PayoutPreferencesPage from './PayoutPreferencesPage/PayoutPreferencesPage';
|
||||
import ProfilePage from './ProfilePage/ProfilePage';
|
||||
import ProfileSettingsForm from './ProfileSettingsForm/ProfileSettingsForm';
|
||||
import ProfileSettingsPage from './ProfileSettingsPage/ProfileSettingsPage';
|
||||
import SalePage from './SalePage/SalePage';
|
||||
import SearchPage from './SearchPage/SearchPage';
|
||||
import SecurityPage from './SecurityPage/SecurityPage';
|
||||
|
|
@ -38,7 +39,6 @@ export {
|
|||
AuthenticationPage,
|
||||
BookingDatesForm,
|
||||
ChangeAccountPasswordForm,
|
||||
ChangePasswordForm,
|
||||
CheckoutPage,
|
||||
ContactDetailsPage,
|
||||
EditListingDescriptionForm,
|
||||
|
|
@ -46,9 +46,8 @@ export {
|
|||
EditListingPage,
|
||||
EditListingPhotosForm,
|
||||
EditListingPricingForm,
|
||||
EditProfilePage,
|
||||
EmailVerificationPage,
|
||||
EmailVerificationForm,
|
||||
EmailVerificationPage,
|
||||
InboxPage,
|
||||
LandingPage,
|
||||
ListingPage,
|
||||
|
|
@ -57,12 +56,15 @@ export {
|
|||
ManageListingsPage,
|
||||
NotFoundPage,
|
||||
OrderPage,
|
||||
PasswordChangePage,
|
||||
PasswordForgottenForm,
|
||||
PasswordForgottenPage,
|
||||
PasswordResetForm,
|
||||
PasswordResetPage,
|
||||
PayoutDetailsForm,
|
||||
PayoutPreferencesPage,
|
||||
ProfilePage,
|
||||
ProfileSettingsForm,
|
||||
ProfileSettingsPage,
|
||||
SalePage,
|
||||
SearchPage,
|
||||
SecurityPage,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import CheckoutPage from './CheckoutPage/CheckoutPage.duck';
|
|||
import EditListingPage from './EditListingPage/EditListingPage.duck';
|
||||
import InboxPage from './InboxPage/InboxPage.duck';
|
||||
import ListingPage from './ListingPage/ListingPage.duck';
|
||||
import ManageListingsPage from './ManageListingsPage/ManageListingsPage.duck';
|
||||
import OrderPage from './OrderPage/OrderPage.duck';
|
||||
import PasswordResetPage from './PasswordResetPage/PasswordResetPage.duck';
|
||||
import ProfileSettingsPage from './ProfileSettingsPage/ProfileSettingsPage.duck';
|
||||
import SalePage from './SalePage/SalePage.duck';
|
||||
import SearchPage from './SearchPage/SearchPage.duck';
|
||||
import ManageListingsPage from './ManageListingsPage/ManageListingsPage.duck';
|
||||
|
||||
export {
|
||||
CheckoutPage,
|
||||
|
|
@ -19,6 +21,8 @@ export {
|
|||
ListingPage,
|
||||
ManageListingsPage,
|
||||
OrderPage,
|
||||
PasswordResetPage,
|
||||
ProfileSettingsPage,
|
||||
SalePage,
|
||||
SearchPage,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { updatedEntities, denormalisedEntities } from '../util/data';
|
||||
import { TX_STATE_PREAUTHORIZED } from '../util/propTypes';
|
||||
|
||||
// ================ Action types ================ //
|
||||
|
|
@ -217,9 +218,15 @@ export const fetchCurrentUser = () =>
|
|||
}
|
||||
|
||||
return sdk.currentUser
|
||||
.show()
|
||||
.show({ include: ['profileImage'] })
|
||||
.then(response => {
|
||||
dispatch(currentUserShowSuccess(response.data.data));
|
||||
// Temporary denormalization for profileImage
|
||||
// Profile image will be included to users
|
||||
const currentUserId = response.data.data.id;
|
||||
const entities = updatedEntities({}, response.data);
|
||||
const denormalised = denormalisedEntities(entities, 'current-user', [currentUserId]);
|
||||
const currentUser = denormalised[0];
|
||||
dispatch(currentUserShowSuccess(currentUser));
|
||||
})
|
||||
.then(() => {
|
||||
dispatch(fetchCurrentUserHasListings());
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import * as TopbarDesktop from './components/TopbarDesktop/TopbarDesktop.example
|
|||
import * as BookingDatesForm from './containers/BookingDatesForm/BookingDatesForm.example';
|
||||
import * as ChangeAccountPasswordForm
|
||||
from './containers/ChangeAccountPasswordForm/ChangeAccountPasswordForm.example';
|
||||
import * as ChangePasswordForm from './containers/ChangePasswordForm/ChangePasswordForm.example';
|
||||
import * as Colors from './containers/StyleguidePage/Colors.example';
|
||||
import * as EditListingDescriptionForm
|
||||
from './containers/EditListingDescriptionForm/EditListingDescriptionForm.example';
|
||||
|
|
@ -45,6 +44,7 @@ import * as EditListingPricingForm
|
|||
import * as LoginForm from './containers/LoginForm/LoginForm.example';
|
||||
import * as PasswordForgottenForm
|
||||
from './containers/PasswordForgottenForm/PasswordForgottenForm.example';
|
||||
import * as PasswordResetForm from './containers/PasswordResetForm/PasswordResetForm.example';
|
||||
import * as PayoutDetailsForm from './containers/PayoutDetailsForm/PayoutDetailsForm.example';
|
||||
import * as SignupForm from './containers/SignupForm/SignupForm.example';
|
||||
import * as StripePaymentForm from './containers/StripePaymentForm/StripePaymentForm.example';
|
||||
|
|
@ -57,7 +57,6 @@ export {
|
|||
BookingDatesForm,
|
||||
Button,
|
||||
ChangeAccountPasswordForm,
|
||||
ChangePasswordForm,
|
||||
Colors,
|
||||
CurrencyInputField,
|
||||
DateInputField,
|
||||
|
|
@ -80,6 +79,7 @@ export {
|
|||
NamedLink,
|
||||
PaginationLinks,
|
||||
PasswordForgottenForm,
|
||||
PasswordResetForm,
|
||||
PayoutDetailsForm,
|
||||
ResponsiveImage,
|
||||
SelectField,
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@
|
|||
|
||||
/* Text */
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
|
||||
/* Effects */
|
||||
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ import {
|
|||
CheckoutPage,
|
||||
ContactDetailsPage,
|
||||
EditListingPage,
|
||||
EditProfilePage,
|
||||
InboxPage,
|
||||
LandingPage,
|
||||
ListingPage,
|
||||
ManageListingsPage,
|
||||
NotFoundPage,
|
||||
OrderPage,
|
||||
PasswordChangePage,
|
||||
PasswordForgottenPage,
|
||||
PasswordResetPage,
|
||||
PayoutPreferencesPage,
|
||||
ProfilePage,
|
||||
ProfileSettingsPage,
|
||||
SalePage,
|
||||
SearchPage,
|
||||
SecurityPage,
|
||||
|
|
@ -122,18 +122,16 @@ const routesConfiguration = [
|
|||
exact: true,
|
||||
name: 'ProfilePage',
|
||||
component: props => <ProfilePage {...props} />,
|
||||
routes: [
|
||||
{
|
||||
path: '/u/:displayName/edit',
|
||||
auth: true,
|
||||
exact: true,
|
||||
name: 'EditProfilePage',
|
||||
component: props => <EditProfilePage {...props} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/profile-settings',
|
||||
auth: true,
|
||||
exact: true,
|
||||
name: 'ProfileSettingsPage',
|
||||
component: props => <ProfileSettingsPage {...props} />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
exact: true,
|
||||
|
|
@ -158,12 +156,6 @@ const routesConfiguration = [
|
|||
name: 'PasswordForgottenPage',
|
||||
component: props => <PasswordForgottenPage {...props} />,
|
||||
},
|
||||
{
|
||||
path: '/password/change',
|
||||
exact: true,
|
||||
name: 'PasswordChangePage',
|
||||
component: props => <PasswordChangePage {...props} />,
|
||||
},
|
||||
{
|
||||
path: '/inbox',
|
||||
auth: true,
|
||||
|
|
@ -306,9 +298,19 @@ const routesConfiguration = [
|
|||
|
||||
// Do not change this path!
|
||||
//
|
||||
// The API expects that the Starter App implements /email_verification endpoint
|
||||
// The API expects that the Starter App implements /reset-password endpoint
|
||||
{
|
||||
path: '/email_verification',
|
||||
path: '/reset-password',
|
||||
exact: true,
|
||||
name: 'PasswordResetPage',
|
||||
component: props => <PasswordResetPage {...props} />,
|
||||
},
|
||||
|
||||
// Do not change this path!
|
||||
//
|
||||
// The API expects that the Starter App implements /verify-email endpoint
|
||||
{
|
||||
path: '/verify-email',
|
||||
auth: true,
|
||||
authPage: 'LoginPage',
|
||||
exact: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
{
|
||||
"AddImages.couldNotReadFile": "Could not read file",
|
||||
"AddImages.upload": "Uploading",
|
||||
"AuthenticationPage.emailAlreadyInUse": "An account already exists with this email address. Try logging in instead.",
|
||||
"AuthenticationPage.loginFailed": "The email and password you entered did not match our records. Please double-check and try again.",
|
||||
"AuthenticationPage.loginLinkText": "Login",
|
||||
|
|
@ -22,6 +20,7 @@
|
|||
"BookingDatesForm.bookingStartTitle": "Start date",
|
||||
"BookingDatesForm.listingCurrencyInvalid": "Oops, the currency of the listing doesn't match the currency of the marketplace.",
|
||||
"BookingDatesForm.listingPriceMissing": "Oops, this listing has no price!",
|
||||
"BookingDatesForm.ownListing": "You won't be able to book your own listing.",
|
||||
"BookingDatesForm.placeholder": "mm/dd/yyyy",
|
||||
"BookingDatesForm.priceBreakdownTitle": "Booking breakdown",
|
||||
"BookingDatesForm.requestToBook": "Request to book",
|
||||
|
|
@ -34,8 +33,8 @@
|
|||
"CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.",
|
||||
"CheckoutPage.paymentTitle": "Payment",
|
||||
"CheckoutPage.priceBreakdownTitle": "Booking breakdown",
|
||||
"CheckoutPage.title": "Book {listingTitle}",
|
||||
"CheckoutPage.speculateTransactionError": "Failed to fetch breakdown information. Please try again.",
|
||||
"CheckoutPage.title": "Book {listingTitle}",
|
||||
"DateInput.clearDate": "Clear Date",
|
||||
"DateInput.closeDatePicker": "Close",
|
||||
"DateInput.defaultPlaceholder": "Date input",
|
||||
|
|
@ -106,24 +105,21 @@
|
|||
"EditListingWizard.tabLabelLocation": "Location",
|
||||
"EditListingWizard.tabLabelPhotos": "Photos",
|
||||
"EditListingWizard.tabLabelPricing": "Pricing",
|
||||
"EmailVerificationForm.emailVerified": "Your email address is verified",
|
||||
"EmailVerificationForm.finishAccountSetup": "To finish setting up your Saunatime account, we sent an email to {email} to make sure this email is yours, so we can reach your later.",
|
||||
"EmailVerificationForm.finishAccountSetup": "To finish setting up your Saunatime account, we sent an email to {email} to make sure this email is yours.",
|
||||
"EmailVerificationForm.okHomepage": "Ok, got it. Take me to the homepage",
|
||||
"EmailVerificationForm.finishAccountSetup": "We would like to send you email notifications. To allow that, please verify your email {email}.",
|
||||
"EmailVerificationForm.successButtonText": "Continue exploring",
|
||||
"EmailVerificationForm.successText": "Everything looks good, time to get out there – have a great day.",
|
||||
"EmailVerificationForm.successTitle": "Thanks for verifying your email, {name}.",
|
||||
"EmailVerificationForm.verificationFailed": "Could not verify the email address.",
|
||||
"EmailVerificationForm.verify": "Verify my email",
|
||||
"EmailVerificationForm.verifyEmailAddress": "Verify your email address",
|
||||
"EmailVerificationForm.verifyEmailAddress": "Verify your email address",
|
||||
"EmailVerificationForm.verifyEmailAddress": "Verify your email",
|
||||
"EmailVerificationForm.verifying": "Verifying…",
|
||||
"EmailVerificationForm.yourEmailAddressIsVerified": "Your email address {email} is verified.",
|
||||
"EmailVerificationPage.loadingUserInformation": "Loading user information…",
|
||||
"EmailVerificationPage.loadingUserInformation": "Loading user information…",
|
||||
"EmailVerificationPage.title": "Verify your email address",
|
||||
"EmailVerificationPage.title": "Verify your email address",
|
||||
"EmailVerificationPage.title": "Verify your email",
|
||||
"HeroSection.mobileSearchButtonText": "Search saunas",
|
||||
"HeroSection.subTitle": "The largest online community to rent saunas in Finland.",
|
||||
"HeroSection.title": "Book saunas everywhere.",
|
||||
"ImageCarousel.imageAltText": "Image {index}/{count}",
|
||||
"ImageFromFile.couldNotReadFile": "Could not read file",
|
||||
"InboxPage.fetchFailed": "Could not load all messages. Please try again.",
|
||||
"InboxPage.noOrdersFound": "You haven't made any bookings.",
|
||||
"InboxPage.noSalesFound": "Nobody has booked anything from you yet.",
|
||||
|
|
@ -142,7 +138,10 @@
|
|||
"ListingCard.unsupportedPrice": "({currency})",
|
||||
"ListingCard.unsupportedPriceTitle": "Unsupported currency ({currency})",
|
||||
"ListingPage.bookingHelp": "Start by choosing your dates.",
|
||||
"ListingPage.bookingHelpClosedListing": "Sorry, this listing has been closed.",
|
||||
"ListingPage.bookingTitle": "Book {title}",
|
||||
"ListingPage.closedListing": "This listing has been closed and can't be booked.",
|
||||
"ListingPage.closedListingButtonText": "Sorry, this listing has been closed.",
|
||||
"ListingPage.ctaButtonMessage": "Request to book",
|
||||
"ListingPage.descriptionTitle": "About this sauna",
|
||||
"ListingPage.editListing": "Edit listing",
|
||||
|
|
@ -150,6 +149,7 @@
|
|||
"ListingPage.loadingListingData": "Loading listing data",
|
||||
"ListingPage.locationTitle": "Location",
|
||||
"ListingPage.noListingData": "Could not find listing data",
|
||||
"ListingPage.ownClosedListing": "Your listing has been closed and can't be booked.",
|
||||
"ListingPage.ownListing": "This is your own listing.",
|
||||
"ListingPage.perNight": "per night",
|
||||
"ListingPage.viewImagesButton": "View photos ({count})",
|
||||
|
|
@ -160,8 +160,9 @@
|
|||
"LoginForm.passwordLabel": "Password",
|
||||
"LoginForm.passwordPlaceholder": "Enter your password…",
|
||||
"LoginForm.passwordRequired": "This field is required",
|
||||
"ManageListingCard.closedListing": "This listing is closed and hidden from the marketplace.",
|
||||
"ManageListingCard.actionFailed": "Whoops, something went wrong. Please refresh the page and try again.",
|
||||
"ManageListingCard.closeListing": "Close listing",
|
||||
"ManageListingCard.closedListing": "This listing is closed and hidden from the marketplace.",
|
||||
"ManageListingCard.edit": "Edit",
|
||||
"ManageListingCard.openListing": "Open listing",
|
||||
"ManageListingCard.perNight": "per night",
|
||||
|
|
@ -200,6 +201,18 @@
|
|||
"PaginationLinks.next": "Next page",
|
||||
"PaginationLinks.previous": "Previous page",
|
||||
"PaginationLinks.toPage": "Go to page {page}",
|
||||
"PasswordResetForm.passwordLabel": "Your new password",
|
||||
"PasswordResetForm.passwordPlaceholder": "Enter your new password...",
|
||||
"PasswordResetForm.passwordRequired": "This field is required",
|
||||
"PasswordResetForm.passwordTooShort": "Password should be at least {minLength} characters",
|
||||
"PasswordResetForm.submitButtonText": "Reset password",
|
||||
"PasswordResetPage.helpText": "Please provide a new password.",
|
||||
"PasswordResetPage.loginButtonText": "Log in",
|
||||
"PasswordResetPage.mainHeading": "Reset your password",
|
||||
"PasswordResetPage.passwordChangedHeading": "Password changed",
|
||||
"PasswordResetPage.passwordChangedHelpText": "Your password has been changed successfully.",
|
||||
"PasswordResetPage.resetFailed": "Reset failed. Please try again.",
|
||||
"PasswordResetPage.title": "Reset password",
|
||||
"PayoutDetailsForm.addressTitle": "Address",
|
||||
"PayoutDetailsForm.bankDetails": "Bank details",
|
||||
"PayoutDetailsForm.birthdayDatePlaceholder": "dd",
|
||||
|
|
@ -247,6 +260,23 @@
|
|||
"PayoutDetailsForm.streetAddressRequired": "This field is required",
|
||||
"PayoutDetailsForm.submitButtonText": "Save details & publish listing",
|
||||
"PayoutDetailsForm.title": "One more thing: payout preferences",
|
||||
"ProfileSettingsForm.addYourProfilePicture": "+ Add your profile picture…",
|
||||
"ProfileSettingsForm.addYourProfilePictureMobile": "+ Add",
|
||||
"ProfileSettingsForm.changeAvatar": "Change",
|
||||
"ProfileSettingsForm.fileInfo": ".JPG, .GIF or .PNG max. 10 MB",
|
||||
"ProfileSettingsForm.firstNameLabel": "First name",
|
||||
"ProfileSettingsForm.firstNamePlaceholder": "John",
|
||||
"ProfileSettingsForm.firstNameRequired": "This field is required",
|
||||
"ProfileSettingsForm.imageUploadFailed": "Whoopsie, something went wrong - please try again.",
|
||||
"ProfileSettingsForm.lastNameLabel": "Last name",
|
||||
"ProfileSettingsForm.lastNamePlaceholder": "Doe",
|
||||
"ProfileSettingsForm.lastNameRequired": "This field is required",
|
||||
"ProfileSettingsForm.saveChanges": "Save changes",
|
||||
"ProfileSettingsForm.tip": "Tip: Choose an image where your face is recognizable.",
|
||||
"ProfileSettingsForm.updateProfileFailed": "Whoopsie, something went wrong - please try again.",
|
||||
"ProfileSettingsForm.yourName": "Your name",
|
||||
"ProfileSettingsForm.yourProfilePicture": "Your profile picture",
|
||||
"ProfileSettingsPage.title": "Profile settings",
|
||||
"ResponsiveImage.noImage": "No image",
|
||||
"SaleDetailsPanel.bookingBreakdownTitle": "Booking breakdown",
|
||||
"SaleDetailsPanel.listingAcceptedTitle": "Woohoo! You accepted a request from {customerName} for {listingLink}",
|
||||
|
|
@ -289,19 +319,34 @@
|
|||
"SignupForm.signUp": "Sign up",
|
||||
"SignupForm.stripeConnectedAccountAgreementLinkText": "Stripe Connected Account Agreement",
|
||||
"SignupForm.termsOfService": "By confirming I accept the terms and conditions and the {stripeConnectedAccountAgreementLink}",
|
||||
"StripeBankAccountTokenInputField.accountNumberInvalid": "Invalid {country} bank account number",
|
||||
"StripeBankAccountTokenInputField.accountNumberLabel": "Bank account number",
|
||||
"StripeBankAccountTokenInputField.accountNumberLabelIban": "Bank account number (IBAN)",
|
||||
"StripeBankAccountTokenInputField.accountNumberPlaceholder": "Type in bank account number…",
|
||||
"StripeBankAccountTokenInputField.accountNumberPlaceholderIban": "e.g. DE89 3704 0044 0532 0130 00",
|
||||
"StripeBankAccountTokenInputField.accountNumberRequired": "Bank account number is required",
|
||||
"StripeBankAccountTokenInputField.accountNumberRequiredIban": "Bank account number (IBAN) is required",
|
||||
"StripeBankAccountTokenInputField.genericStripeError": "Could not connect account number. Please double-check that your routing number and account number are valid in {country} when using {currency}",
|
||||
"StripeBankAccountTokenInputField.genericStripeErrorIban": "Could not connect account number. Please double-check that your account number is valid in {country} when using {currency}",
|
||||
"StripeBankAccountTokenInputField.routingNumberInvalid": "Invalid {country} routing number",
|
||||
"StripeBankAccountTokenInputField.routingNumberLabel": "Routing number",
|
||||
"StripeBankAccountTokenInputField.routingNumberPlaceholder": "Type in routing number…",
|
||||
"StripeBankAccountTokenInputField.routingNumberRequired": "Routing number is required",
|
||||
"StripeBankAccountTokenInputField.accountNumber.inline": "account number",
|
||||
"StripeBankAccountTokenInputField.accountNumber.invalid": "Invalid {country} bank account number",
|
||||
"StripeBankAccountTokenInputField.accountNumber.label": "Bank account number",
|
||||
"StripeBankAccountTokenInputField.accountNumber.placeholder": "Type in bank account number…",
|
||||
"StripeBankAccountTokenInputField.accountNumber.required": "Bank account number is required",
|
||||
"StripeBankAccountTokenInputField.andBeforeLastItemInAList": " and",
|
||||
"StripeBankAccountTokenInputField.bsb.inline": "BSB",
|
||||
"StripeBankAccountTokenInputField.bsb.invalid": "Invalid {country} BSB",
|
||||
"StripeBankAccountTokenInputField.bsb.label": "BSB",
|
||||
"StripeBankAccountTokenInputField.bsb.placeholder": "Type in BSB…",
|
||||
"StripeBankAccountTokenInputField.bsb.required": "BSB is required",
|
||||
"StripeBankAccountTokenInputField.genericStripeError": "Could not connect account number. Please double-check that your {inputs} are valid in {country}",
|
||||
"StripeBankAccountTokenInputField.genericStripeErrorIban": "Could not connect account number. Please double-check that your account number is valid in {country}",
|
||||
"StripeBankAccountTokenInputField.iban.inline": "IBAN",
|
||||
"StripeBankAccountTokenInputField.iban.invalid": "Invalid {country} bank account number",
|
||||
"StripeBankAccountTokenInputField.iban.label": "Bank account number (IBAN)",
|
||||
"StripeBankAccountTokenInputField.iban.placeholder": "e.g. DE89 3704 0044 0532 0130 00",
|
||||
"StripeBankAccountTokenInputField.iban.required": "Bank account number (IBAN) is required",
|
||||
"StripeBankAccountTokenInputField.routingNumber.inline": "routing number",
|
||||
"StripeBankAccountTokenInputField.routingNumber.invalid": "Invalid {country} routing number",
|
||||
"StripeBankAccountTokenInputField.routingNumber.label": "Routing number",
|
||||
"StripeBankAccountTokenInputField.routingNumber.placeholder": "Type in routing number…",
|
||||
"StripeBankAccountTokenInputField.routingNumber.required": "Routing number is required",
|
||||
"StripeBankAccountTokenInputField.sortCode.inline": "sort code",
|
||||
"StripeBankAccountTokenInputField.sortCode.invalid": "Invalid {country} sort code",
|
||||
"StripeBankAccountTokenInputField.sortCode.label": "Sort code",
|
||||
"StripeBankAccountTokenInputField.sortCode.placeholder": "Type in sort code…",
|
||||
"StripeBankAccountTokenInputField.sortCode.required": "Sort code is required",
|
||||
"StripeBankAccountTokenInputField.unsupportedCountry": "Country not supported: {country}",
|
||||
"StripePaymentForm.creditCardDetails": "Credit card details",
|
||||
"StripePaymentForm.genericError": "Could not handle payment data. Please try again.",
|
||||
|
|
@ -335,6 +380,7 @@
|
|||
"TopbarDesktop.login": "Log in",
|
||||
"TopbarDesktop.logo": "Logo",
|
||||
"TopbarDesktop.logout": "Log out",
|
||||
"TopbarDesktop.profileSettingsLink": "Profile settings",
|
||||
"TopbarDesktop.signup": "Sign up",
|
||||
"TopbarDesktop.yourListingsLink": "Your listings",
|
||||
"TopbarMobileMenu.greeting": "Hello {displayName}",
|
||||
|
|
@ -342,6 +388,7 @@
|
|||
"TopbarMobileMenu.loginLink": "Log in",
|
||||
"TopbarMobileMenu.logoutLink": "Log out",
|
||||
"TopbarMobileMenu.newListingLink": "+ Add your sauna",
|
||||
"TopbarMobileMenu.profileSettingsLink": "Profile settings",
|
||||
"TopbarMobileMenu.signupLink": "Sign up",
|
||||
"TopbarMobileMenu.signupOrLogin": "{signup} or {login}",
|
||||
"TopbarMobileMenu.unauthorizedGreeting": "Hello there,{lineBreak}would you like to {signupOrLogin}?",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue