mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
commit
db07704f1c
34 changed files with 1171 additions and 58 deletions
|
|
@ -18,6 +18,7 @@ export const Empty = {
|
|||
props: {
|
||||
messages: [],
|
||||
hasOlderMessages: false,
|
||||
onOpenReviewModal: noop,
|
||||
onShowOlderMessages: noop,
|
||||
fetchMessagesInProgress: false,
|
||||
},
|
||||
|
|
@ -32,6 +33,7 @@ export const WithoutCurrentUser = {
|
|||
createMessage('msg2', {}, { sender: createUser('user2') }),
|
||||
],
|
||||
hasOlderMessages: false,
|
||||
onOpenReviewModal: noop,
|
||||
onShowOlderMessages: noop,
|
||||
fetchMessagesInProgress: false,
|
||||
},
|
||||
|
|
@ -50,6 +52,7 @@ export const WithCurrentUser = {
|
|||
createMessage('msg5', {}, { sender: createUser('user1') }),
|
||||
],
|
||||
hasOlderMessages: false,
|
||||
onOpenReviewModal: noop,
|
||||
onShowOlderMessages: noop,
|
||||
fetchMessagesInProgress: false,
|
||||
},
|
||||
|
|
@ -79,6 +82,7 @@ export const WithTransitions = {
|
|||
}),
|
||||
messages: [],
|
||||
hasOlderMessages: false,
|
||||
onOpenReviewModal: noop,
|
||||
onShowOlderMessages: noop,
|
||||
fetchMessagesInProgress: false,
|
||||
},
|
||||
|
|
@ -162,6 +166,7 @@ export const WithMessagesTransitionsAndReviews = {
|
|||
),
|
||||
],
|
||||
hasOlderMessages: false,
|
||||
onOpenReviewModal: noop,
|
||||
onShowOlderMessages: noop,
|
||||
fetchMessagesInProgress: false,
|
||||
},
|
||||
|
|
@ -204,6 +209,7 @@ export const WithAReviewFromBothUsers = {
|
|||
}),
|
||||
messages: [],
|
||||
hasOlderMessages: false,
|
||||
onOpenReviewModal: noop,
|
||||
onShowOlderMessages: noop,
|
||||
fetchMessagesInProgress: false,
|
||||
},
|
||||
|
|
@ -278,6 +284,7 @@ class PagedFeed extends Component {
|
|||
transaction,
|
||||
messages,
|
||||
hasOlderMessages: !this.state.showAllMessages,
|
||||
onOpenReviewModal: noop,
|
||||
onShowOlderMessages: handleShowOlder,
|
||||
fetchMessagesInProgress: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -76,19 +76,14 @@ const hasUserLeftAReviewFirst = (userRole, lastTransition) => {
|
|||
);
|
||||
};
|
||||
|
||||
const onLeaveAReview = () => {
|
||||
// TODO: Open up a review modal
|
||||
|
||||
console.log('Leave a review button clicked');
|
||||
};
|
||||
|
||||
const resolveTransitionMessage = (
|
||||
transition,
|
||||
lastTransition,
|
||||
listingTitle,
|
||||
ownRole,
|
||||
otherUsersName,
|
||||
intl
|
||||
intl,
|
||||
onOpenReviewModal
|
||||
) => {
|
||||
const isOwnTransition = transition.by === ownRole;
|
||||
const currentTransition = transition.transition;
|
||||
|
|
@ -130,7 +125,7 @@ const resolveTransitionMessage = (
|
|||
// if current user is not the first to leave a review
|
||||
const reviewLink =
|
||||
deliveredState || !hasUserLeftAReviewFirst(ownRole, lastTransition) ? (
|
||||
<InlineTextButton onClick={onLeaveAReview}>
|
||||
<InlineTextButton onClick={onOpenReviewModal}>
|
||||
<FormattedMessage id="ActivityFeed.leaveAReview" values={{ displayName }} />
|
||||
</InlineTextButton>
|
||||
) : null;
|
||||
|
|
@ -144,7 +139,7 @@ const resolveTransitionMessage = (
|
|||
// show the leave a review link if current user is not the first
|
||||
// one to leave a review
|
||||
const reviewLink = !hasUserLeftAReviewFirst(ownRole, lastTransition) ? (
|
||||
<InlineTextButton onClick={onLeaveAReview}>
|
||||
<InlineTextButton onClick={onOpenReviewModal}>
|
||||
<FormattedMessage id="ActivityFeed.leaveAReviewSecond" values={{ displayName }} />
|
||||
</InlineTextButton>
|
||||
) : null;
|
||||
|
|
@ -178,7 +173,7 @@ const reviewByAuthorId = (transaction, userId) => {
|
|||
};
|
||||
|
||||
const Transition = props => {
|
||||
const { transition, transaction, currentUser, intl } = props;
|
||||
const { transition, transaction, currentUser, intl, onOpenReviewModal } = props;
|
||||
|
||||
const currentTransaction = ensureTransaction(transaction);
|
||||
const customer = currentTransaction.customer;
|
||||
|
|
@ -202,7 +197,8 @@ const Transition = props => {
|
|||
listingTitle,
|
||||
ownRole,
|
||||
otherUsersName,
|
||||
intl
|
||||
intl,
|
||||
onOpenReviewModal
|
||||
);
|
||||
const currentTransition = transition.transition;
|
||||
|
||||
|
|
@ -252,6 +248,7 @@ Transition.propTypes = {
|
|||
transaction: propTypes.transaction.isRequired,
|
||||
currentUser: propTypes.currentUser.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onOpenReviewModal: func.isRequired,
|
||||
};
|
||||
|
||||
const EmptyTransition = () => {
|
||||
|
|
@ -296,6 +293,7 @@ export const ActivityFeedComponent = props => {
|
|||
transaction,
|
||||
currentUser,
|
||||
hasOlderMessages,
|
||||
onOpenReviewModal,
|
||||
onShowOlderMessages,
|
||||
fetchMessagesInProgress,
|
||||
intl,
|
||||
|
|
@ -329,6 +327,7 @@ export const ActivityFeedComponent = props => {
|
|||
transaction={transaction}
|
||||
currentUser={currentUser}
|
||||
intl={intl}
|
||||
onOpenReviewModal={onOpenReviewModal}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
|
@ -398,6 +397,7 @@ ActivityFeedComponent.propTypes = {
|
|||
transaction: propTypes.transaction,
|
||||
messages: arrayOf(propTypes.message),
|
||||
hasOlderMessages: bool.isRequired,
|
||||
onOpenReviewModal: func.isRequired,
|
||||
onShowOlderMessages: func.isRequired,
|
||||
fetchMessagesInProgress: bool.isRequired,
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ describe('ActivityFeed', () => {
|
|||
createMessage('msg2', {}, { sender: createUser('user2') }),
|
||||
],
|
||||
hasOlderMessages: false,
|
||||
onOpenReviewModal: noop,
|
||||
onShowOlderMessages: noop,
|
||||
fetchMessagesInProgress: false,
|
||||
intl: fakeIntl,
|
||||
|
|
|
|||
52
src/components/FieldReviewRating/FieldReviewRating.css
Normal file
52
src/components/FieldReviewRating/FieldReviewRating.css
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.ratingFieldSet {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* fieldset can't use flexbox hence this wrapper exists */
|
||||
.rating {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.rateInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.star {
|
||||
fill: var(--matterColorNegative);
|
||||
}
|
||||
|
||||
/***** CSS Magic to Highlight Stars on Hover *****/
|
||||
|
||||
/* Star order: reverse expected (5 -> 1) and also input before label */
|
||||
|
||||
/* show actived star when checked */
|
||||
/* and actived star when hovering over a star */
|
||||
/* and show previous stars also as activated */
|
||||
.rating > .rateInput:checked ~ .label > .star,
|
||||
.rating > .label:hover > .star,
|
||||
.rating > .label:hover ~ .label > .star {
|
||||
fill: var(--marketplaceColor);
|
||||
}
|
||||
|
||||
/* Darken hovered star when changing rating i.e it already is active */
|
||||
/* and darken hovered star too when changing rating (hovering inside current selection) */
|
||||
/* and darken current selection inside hovered selection */
|
||||
/* and darken hovered selection inside current selection */
|
||||
.rating > .rateInput:checked + .label:hover > .star,
|
||||
.rating > .rateInput:checked ~ .label:hover > .star,
|
||||
.rating > .label:hover ~ .rateInput:checked ~ .label > .star,
|
||||
.rating > .rateInput:checked ~ .label:hover ~ .label > .star {
|
||||
fill: var(--marketplaceColorDark);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/* eslint-disable no-console */
|
||||
import React from 'react';
|
||||
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
|
||||
import * as validators from '../../util/validators';
|
||||
import { Button } from '../../components';
|
||||
import FieldReviewRating from './FieldReviewRating';
|
||||
|
||||
const formName = 'Styleguide.FieldReviewRating.Form';
|
||||
|
||||
const FormComponent = props => {
|
||||
const { form, handleSubmit, invalid, pristine, submitting } = props;
|
||||
const required = validators.required('This field is required');
|
||||
const submitDisabled = invalid || pristine || submitting;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldReviewRating
|
||||
id={`${form}.rate1`}
|
||||
name="rating"
|
||||
label="Rate your experience"
|
||||
validate={required}
|
||||
/>
|
||||
<Button style={{ marginTop: 24 }} type="submit" disabled={submitDisabled}>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
FormComponent.propTypes = formPropTypes;
|
||||
|
||||
const Form = reduxForm({
|
||||
form: formName,
|
||||
})(FormComponent);
|
||||
|
||||
export const StarRating = {
|
||||
component: Form,
|
||||
props: {
|
||||
onSubmit: values => {
|
||||
console.log('submit values:', values);
|
||||
},
|
||||
},
|
||||
group: 'inputs',
|
||||
};
|
||||
140
src/components/FieldReviewRating/FieldReviewRating.js
Normal file
140
src/components/FieldReviewRating/FieldReviewRating.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { intlShape, injectIntl } from 'react-intl';
|
||||
import { Field } from 'redux-form';
|
||||
import classNames from 'classnames';
|
||||
import { IconReviewStar, ValidationError } from '../../components';
|
||||
|
||||
import css from './FieldReviewRating.css';
|
||||
|
||||
class FieldReviewRatingComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.clearOnUnmount) {
|
||||
this.props.input.onChange('');
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
this.props.input.onChange(event.target.value);
|
||||
}
|
||||
|
||||
render() {
|
||||
/* eslint-disable no-unused-vars */
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
inputRootClass,
|
||||
clearOnUnmount,
|
||||
customErrorText,
|
||||
id,
|
||||
intl,
|
||||
label,
|
||||
input,
|
||||
meta,
|
||||
...rest
|
||||
} = this.props;
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
const { touched, error } = meta;
|
||||
const errorText = customErrorText || error;
|
||||
const fieldMeta = { touched, error: errorText };
|
||||
|
||||
const { value, ...restInputProps } = input;
|
||||
const inputProps = { ...restInputProps, type: 'radio', name: 'rating', ...rest };
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
|
||||
const createStarRating = starCount => {
|
||||
let inputsAndLabels = [];
|
||||
|
||||
// Star inpu order: reverse order expected (5 -> 1) and also input before label
|
||||
// This is due to CSS selectors.
|
||||
// Sibling combinator (~) selects following siblings, but we want to select previous siblings
|
||||
for (let i = starCount; i > 0; i--) {
|
||||
const inputValue = `${i}`;
|
||||
const starId = `star${i}`;
|
||||
const inputId = `${id}.${starId}`;
|
||||
|
||||
inputsAndLabels.push(
|
||||
<input
|
||||
key={inputId}
|
||||
id={inputId}
|
||||
className={css.rateInput}
|
||||
value={inputValue}
|
||||
checked={value === inputValue}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
|
||||
inputsAndLabels.push(
|
||||
<label
|
||||
key={`label.${inputId}`}
|
||||
className={css.label}
|
||||
htmlFor={inputId}
|
||||
title={intl.formatMessage({ id: `FieldReviewRating.${starId}` })}
|
||||
>
|
||||
<IconReviewStar rootClassName={css.star} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return inputsAndLabels;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<fieldset
|
||||
className={css.ratingFieldSet}
|
||||
ref={c => {
|
||||
this.ratingFieldSet = c;
|
||||
}}
|
||||
>
|
||||
{label ? <legend>{label}</legend> : null}
|
||||
<div className={css.rating}>{createStarRating(5)}</div>
|
||||
</fieldset>
|
||||
<ValidationError fieldMeta={fieldMeta} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FieldReviewRatingComponent.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
clearOnUnmount: false,
|
||||
customErrorText: null,
|
||||
label: null,
|
||||
};
|
||||
|
||||
const { string, bool, shape, func, object } = PropTypes;
|
||||
|
||||
FieldReviewRatingComponent.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
clearOnUnmount: bool,
|
||||
id: string.isRequired,
|
||||
label: string,
|
||||
|
||||
// Error message that can be manually passed to input field,
|
||||
// overrides default validation message
|
||||
customErrorText: string,
|
||||
|
||||
// Generated by redux-form's Field component
|
||||
input: shape({
|
||||
onChange: func.isRequired,
|
||||
}).isRequired,
|
||||
meta: object.isRequired,
|
||||
|
||||
// from injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const FieldReviewRating = props => {
|
||||
return <Field component={FieldReviewRatingComponent} {...props} />;
|
||||
};
|
||||
|
||||
export default injectIntl(FieldReviewRating);
|
||||
5
src/components/IconReviewUser/IconReviewUser.css
Normal file
5
src/components/IconReviewUser/IconReviewUser.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
stroke: var(--marketplaceColor);
|
||||
}
|
||||
7
src/components/IconReviewUser/IconReviewUser.example.js
Normal file
7
src/components/IconReviewUser/IconReviewUser.example.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import IconReviewUser from './IconReviewUser';
|
||||
|
||||
export const Icon = {
|
||||
component: IconReviewUser,
|
||||
props: {},
|
||||
group: 'icons',
|
||||
};
|
||||
44
src/components/IconReviewUser/IconReviewUser.js
Normal file
44
src/components/IconReviewUser/IconReviewUser.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import css from './IconReviewUser.css';
|
||||
|
||||
const IconReviewUser = props => {
|
||||
const { className, rootClassName } = props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={classes}
|
||||
width="46"
|
||||
height="47"
|
||||
viewBox="0 0 46 47"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path strokeWidth="2.75" d="M8 45h32V2H8z" />
|
||||
<path d="M8 2H4c-1.1 0-2 .84-2 1.87v39.26C2 44.16 2.9 45 4 45h4" strokeWidth="2.5" />
|
||||
<path
|
||||
d="M26.35 24v2.77l4.822 1.34c1.005.278 1.828 1.336 1.828 2.352V36H14v-5.538c0-1.016.823-2.074 1.826-2.352l4.824-1.34V24M29 18c0 3.315-2.464 6-5.5 6-3.038 0-5.5-2.685-5.5-6 0-3.313 2.462-6 5.5-6 3.036 0 5.5 2.687 5.5 6z"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M28.5 17.01c-.847 1-3.27.996-4.183-1.01-1.694 2-4.447 2-5.817.858M41 5h1.333C43.8 5 45 6.62 45 8.6V14h-4M41 14h4v9h-4M41 23h4v10h-4M41 33h4v5.4c0 1.98-1.2 3.6-2.667 3.6H41"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
IconReviewUser.defaultProps = { className: null, rootClassName: null };
|
||||
|
||||
const { string } = PropTypes;
|
||||
|
||||
IconReviewUser.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
};
|
||||
|
||||
export default IconReviewUser;
|
||||
|
|
@ -44,6 +44,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
}
|
||||
|
||||
/* Content is explicitly hidden (this default can be overridden with passed-in class) */
|
||||
/* The use case for having both .isOpen and .isClosed is ModalInMobile use case */
|
||||
/* where desktop layout should not get any styling from Modal component. */
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import { createSlug } from '../../util/urlHelpers';
|
|||
import { ensureListing, ensureTransaction, ensureUser, userDisplayName } from '../../util/data';
|
||||
import { isMobileSafari } from '../../util/userAgent';
|
||||
import {
|
||||
ActivityFeed,
|
||||
AvatarMedium,
|
||||
BookingBreakdown,
|
||||
NamedLink,
|
||||
ResponsiveImage,
|
||||
AvatarMedium,
|
||||
ActivityFeed,
|
||||
ReviewModal,
|
||||
} from '../../components';
|
||||
import { SendMessageForm } from '../../containers';
|
||||
|
||||
|
|
@ -82,8 +83,31 @@ const orderMessage = (transaction, providerName) => {
|
|||
export class OrderDetailsPanelComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { sendMessageFormFocused: false };
|
||||
this.state = {
|
||||
sendMessageFormFocused: false,
|
||||
isReviewModalOpen: false,
|
||||
reviewSubmitted: false,
|
||||
};
|
||||
this.onOpenReviewModal = this.onOpenReviewModal.bind(this);
|
||||
this.onSubmitReview = this.onSubmitReview.bind(this);
|
||||
}
|
||||
|
||||
onOpenReviewModal() {
|
||||
this.setState({ isReviewModalOpen: true });
|
||||
}
|
||||
|
||||
onSubmitReview(values) {
|
||||
const { onSendReview, transaction } = this.props;
|
||||
const currentTransaction = ensureTransaction(transaction);
|
||||
const { reviewRating, reviewContent } = values;
|
||||
const rating = Number.parseInt(reviewRating, 10);
|
||||
onSendReview(currentTransaction, rating, reviewContent)
|
||||
.then(r => this.setState({ isReviewModalOpen: false, reviewSubmitted: true }))
|
||||
.catch(e => {
|
||||
// Do nothing.
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
rootClassName,
|
||||
|
|
@ -97,6 +121,9 @@ export class OrderDetailsPanelComponent extends Component {
|
|||
fetchMessagesError,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
onManageDisableScrolling,
|
||||
onShowMoreMessages,
|
||||
onSendMessage,
|
||||
onResetForm,
|
||||
|
|
@ -200,6 +227,7 @@ export class OrderDetailsPanelComponent extends Component {
|
|||
transaction={currentTransaction}
|
||||
currentUser={currentUser}
|
||||
hasOlderMessages={hasOlderMessages && !fetchMessagesInProgress}
|
||||
onOpenReviewModal={this.onOpenReviewModal}
|
||||
onShowOlderMessages={handleShowOlderMessages}
|
||||
fetchMessagesInProgress={fetchMessagesInProgress}
|
||||
/>
|
||||
|
|
@ -330,6 +358,17 @@ export class OrderDetailsPanelComponent extends Component {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewModal
|
||||
id="ReviewOrderModal"
|
||||
isOpen={this.state.isReviewModalOpen}
|
||||
onCloseModal={() => this.setState({ isReviewModalOpen: false })}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
onSubmitReview={this.onSubmitReview}
|
||||
revieweeName={authorDisplayName}
|
||||
reviewSent={this.state.reviewSubmitted}
|
||||
sendReviewInProgress={sendReviewInProgress}
|
||||
sendReviewError={sendReviewError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -341,6 +380,7 @@ OrderDetailsPanelComponent.defaultProps = {
|
|||
currentUser: null,
|
||||
fetchMessagesError: null,
|
||||
sendMessageError: null,
|
||||
sendReviewError: null,
|
||||
};
|
||||
|
||||
const { string, arrayOf, bool, func, number } = PropTypes;
|
||||
|
|
@ -358,8 +398,12 @@ OrderDetailsPanelComponent.propTypes = {
|
|||
fetchMessagesError: propTypes.error,
|
||||
sendMessageInProgress: bool.isRequired,
|
||||
sendMessageError: propTypes.error,
|
||||
sendReviewInProgress: bool.isRequired,
|
||||
sendReviewError: propTypes.error,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
onShowMoreMessages: func.isRequired,
|
||||
onSendMessage: func.isRequired,
|
||||
onSendReview: func.isRequired,
|
||||
onResetForm: func.isRequired,
|
||||
|
||||
// from injectIntl
|
||||
|
|
|
|||
|
|
@ -78,8 +78,12 @@ describe('OrderDetailsPanel', () => {
|
|||
initialMessageFailed: false,
|
||||
fetchMessagesInProgress: false,
|
||||
sendMessageInProgress: false,
|
||||
sendReviewInProgress: false,
|
||||
onManageDisableScrolling: noop,
|
||||
onOpenReviewModal: noop,
|
||||
onShowMoreMessages: noop,
|
||||
onSendMessage: noop,
|
||||
onSendReview: noop,
|
||||
onResetForm: noop,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@ exports[`OrderDetailsPanel accepted matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -644,6 +645,17 @@ exports[`OrderDetailsPanel accepted matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewOrderModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="provider display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -945,6 +957,7 @@ exports[`OrderDetailsPanel autodeclined matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -1279,6 +1292,17 @@ exports[`OrderDetailsPanel autodeclined matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewOrderModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="provider display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -1592,6 +1616,7 @@ exports[`OrderDetailsPanel canceled matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -1926,6 +1951,17 @@ exports[`OrderDetailsPanel canceled matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewOrderModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="provider display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -2227,6 +2263,7 @@ exports[`OrderDetailsPanel declined matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -2561,6 +2598,17 @@ exports[`OrderDetailsPanel declined matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewOrderModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="provider display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -2862,6 +2910,7 @@ exports[`OrderDetailsPanel delivered matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -3196,6 +3245,17 @@ exports[`OrderDetailsPanel delivered matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewOrderModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="provider display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -3520,6 +3580,7 @@ exports[`OrderDetailsPanel preauthorized matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -3854,5 +3915,16 @@ exports[`OrderDetailsPanel preauthorized matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewOrderModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="provider display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
33
src/components/ReviewModal/ReviewModal.css
Normal file
33
src/components/ReviewModal/ReviewModal.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
@apply --marketplaceModalBaseStyles;
|
||||
padding-top: 70px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
flex-basis: 567px;
|
||||
}
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Icon of the modal */
|
||||
.modalIcon {
|
||||
@apply --marketplaceModalIconStyles;
|
||||
}
|
||||
|
||||
/* Title of the modal */
|
||||
.modalTitle {
|
||||
@apply --marketplaceModalTitleStyles;
|
||||
}
|
||||
|
||||
.reviewee {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Paragraph for the Modal */
|
||||
.modalMessage {
|
||||
@apply --marketplaceModalParagraphStyles;
|
||||
}
|
||||
77
src/components/ReviewModal/ReviewModal.js
Normal file
77
src/components/ReviewModal/ReviewModal.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { IconReviewUser, Modal } from '../../components';
|
||||
import { ReviewForm } from '../../containers';
|
||||
|
||||
import css from './ReviewModal.css';
|
||||
|
||||
const ReviewModal = props => {
|
||||
const {
|
||||
className,
|
||||
rootClassName,
|
||||
id,
|
||||
intl,
|
||||
isOpen,
|
||||
onCloseModal,
|
||||
onManageDisableScrolling,
|
||||
onSubmitReview,
|
||||
revieweeName,
|
||||
reviewSent,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
} = props;
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const closeButtonMessage = intl.formatMessage({ id: 'ReviewModal.later' });
|
||||
const reviewee = <span className={css.reviewee}>{revieweeName}</span>;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id={id}
|
||||
containerClassName={classes}
|
||||
contentClassName={css.modalContent}
|
||||
isOpen={isOpen}
|
||||
onClose={onCloseModal}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
closeButtonMessage={closeButtonMessage}
|
||||
>
|
||||
<IconReviewUser className={css.modalIcon} />
|
||||
<p className={css.modalTitle}>
|
||||
<FormattedMessage id="ReviewModal.title" values={{ revieweeName: reviewee }} />
|
||||
</p>
|
||||
<p className={css.modalMessage}>
|
||||
<FormattedMessage id="ReviewModal.description" />
|
||||
</p>
|
||||
<ReviewForm
|
||||
onSubmit={onSubmitReview}
|
||||
reviewSent={reviewSent}
|
||||
sendReviewInProgress={sendReviewInProgress}
|
||||
sendReviewError={sendReviewError}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const { bool, string } = PropTypes;
|
||||
|
||||
ReviewModal.defaultProps = {
|
||||
className: null,
|
||||
rootClassName: null,
|
||||
reviewSent: false,
|
||||
sendReviewInProgress: false,
|
||||
sendReviewError: null,
|
||||
};
|
||||
|
||||
ReviewModal.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
intl: intlShape.isRequired,
|
||||
reviewSent: bool,
|
||||
sendReviewInProgress: bool,
|
||||
sendReviewError: propTypes.error,
|
||||
};
|
||||
|
||||
export default injectIntl(ReviewModal);
|
||||
|
|
@ -7,14 +7,15 @@ import { createSlug } from '../../util/urlHelpers';
|
|||
import { ensureListing, ensureTransaction, ensureUser, userDisplayName } from '../../util/data';
|
||||
import { isMobileSafari } from '../../util/userAgent';
|
||||
import {
|
||||
ActivityFeed,
|
||||
AvatarLarge,
|
||||
AvatarMedium,
|
||||
BookingBreakdown,
|
||||
NamedLink,
|
||||
ResponsiveImage,
|
||||
PrimaryButton,
|
||||
ResponsiveImage,
|
||||
ReviewModal,
|
||||
SecondaryButton,
|
||||
ActivityFeed,
|
||||
} from '../../components';
|
||||
import { SendMessageForm } from '../../containers';
|
||||
|
||||
|
|
@ -91,8 +92,31 @@ const saleInfoText = (transaction, customerName) => {
|
|||
export class SaleDetailsPanelComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { sendMessageFormFocused: false };
|
||||
this.state = {
|
||||
sendMessageFormFocused: false,
|
||||
isReviewModalOpen: false,
|
||||
reviewSubmitted: false,
|
||||
};
|
||||
this.onOpenReviewModal = this.onOpenReviewModal.bind(this);
|
||||
this.onSubmitReview = this.onSubmitReview.bind(this);
|
||||
}
|
||||
|
||||
onOpenReviewModal() {
|
||||
this.setState({ isReviewModalOpen: true });
|
||||
}
|
||||
|
||||
onSubmitReview(values) {
|
||||
const { onSendReview, transaction } = this.props;
|
||||
const currentTransaction = ensureTransaction(transaction);
|
||||
const { reviewRating, reviewContent } = values;
|
||||
const rating = Number.parseInt(reviewRating, 10);
|
||||
onSendReview(currentTransaction, rating, reviewContent)
|
||||
.then(r => this.setState({ isReviewModalOpen: false, reviewSubmitted: true }))
|
||||
.catch(e => {
|
||||
// Do nothing.
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
rootClassName,
|
||||
|
|
@ -111,6 +135,9 @@ export class SaleDetailsPanelComponent extends Component {
|
|||
messages,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
onManageDisableScrolling,
|
||||
onShowMoreMessages,
|
||||
onSendMessage,
|
||||
onResetForm,
|
||||
|
|
@ -196,6 +223,7 @@ export class SaleDetailsPanelComponent extends Component {
|
|||
transaction={currentTransaction}
|
||||
currentUser={currentUser}
|
||||
hasOlderMessages={hasOlderMessages && !fetchMessagesInProgress}
|
||||
onOpenReviewModal={this.onOpenReviewModal}
|
||||
onShowOlderMessages={handleShowOlderMessages}
|
||||
fetchMessagesInProgress={fetchMessagesInProgress}
|
||||
/>
|
||||
|
|
@ -361,6 +389,17 @@ export class SaleDetailsPanelComponent extends Component {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewModal
|
||||
id="ReviewSaleModal"
|
||||
isOpen={this.state.isReviewModalOpen}
|
||||
onCloseModal={() => this.setState({ isReviewModalOpen: false })}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
onSubmitReview={this.onSubmitReview}
|
||||
revieweeName={customerDisplayName}
|
||||
reviewSent={this.state.reviewSubmitted}
|
||||
sendReviewInProgress={sendReviewInProgress}
|
||||
sendReviewError={sendReviewError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -374,6 +413,7 @@ SaleDetailsPanelComponent.defaultProps = {
|
|||
declineSaleError: null,
|
||||
fetchMessagesError: null,
|
||||
sendMessageError: null,
|
||||
sendReviewError: null,
|
||||
};
|
||||
|
||||
const { bool, func, string, arrayOf, number } = PropTypes;
|
||||
|
|
@ -396,8 +436,12 @@ SaleDetailsPanelComponent.propTypes = {
|
|||
messages: arrayOf(propTypes.message).isRequired,
|
||||
sendMessageInProgress: bool.isRequired,
|
||||
sendMessageError: propTypes.error,
|
||||
sendReviewInProgress: bool.isRequired,
|
||||
sendReviewError: propTypes.error,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
onShowMoreMessages: func.isRequired,
|
||||
onSendMessage: func.isRequired,
|
||||
onSendReview: func.isRequired,
|
||||
onResetForm: func.isRequired,
|
||||
|
||||
// from injectIntl
|
||||
|
|
|
|||
|
|
@ -80,8 +80,12 @@ describe('SaleDetailsPanel', () => {
|
|||
],
|
||||
fetchMessagesInProgress: false,
|
||||
sendMessageInProgress: false,
|
||||
sendReviewInProgress: false,
|
||||
onManageDisableScrolling: noop,
|
||||
onOpenReviewModal: noop,
|
||||
onShowMoreMessages: noop,
|
||||
onSendMessage: noop,
|
||||
onSendReview: noop,
|
||||
onResetForm: noop,
|
||||
intl: fakeIntl,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -304,6 +304,7 @@ exports[`SaleDetailsPanel accepted matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -584,6 +585,17 @@ exports[`SaleDetailsPanel accepted matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewSaleModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="customer1 display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -891,6 +903,7 @@ exports[`SaleDetailsPanel autodeclined matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -1171,6 +1184,17 @@ exports[`SaleDetailsPanel autodeclined matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewSaleModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="customer1 display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -1478,6 +1502,7 @@ exports[`SaleDetailsPanel canceled matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -1758,6 +1783,17 @@ exports[`SaleDetailsPanel canceled matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewSaleModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="customer1 display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -2065,6 +2101,7 @@ exports[`SaleDetailsPanel declined matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -2345,6 +2382,17 @@ exports[`SaleDetailsPanel declined matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewSaleModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="customer1 display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -2652,6 +2700,7 @@ exports[`SaleDetailsPanel delivered matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -2932,6 +2981,17 @@ exports[`SaleDetailsPanel delivered matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewSaleModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="customer1 display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -3249,6 +3309,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
onOpenReviewModal={[Function]}
|
||||
onShowOlderMessages={[Function]}
|
||||
transaction={
|
||||
Object {
|
||||
|
|
@ -3585,5 +3646,16 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InjectIntl(ReviewModal)
|
||||
id="ReviewSaleModal"
|
||||
isOpen={false}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onSubmitReview={[Function]}
|
||||
reviewSent={false}
|
||||
revieweeName="customer1 display name"
|
||||
sendReviewError={null}
|
||||
sendReviewInProgress={false}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { ValidationError, ExpandingTextarea } from '../../components';
|
|||
|
||||
import css from './TextInputField.css';
|
||||
|
||||
const CONTENT_MAX_LENGTH = 5000;
|
||||
|
||||
class TextInputFieldComponent extends Component {
|
||||
componentWillUnmount() {
|
||||
if (this.props.clearOnUnmount) {
|
||||
|
|
@ -52,7 +54,7 @@ class TextInputFieldComponent extends Component {
|
|||
[css.textarea]: isTextarea,
|
||||
});
|
||||
const inputProps = isTextarea
|
||||
? { className: inputClasses, id, ...input, ...rest }
|
||||
? { className: inputClasses, id, rows: 1, maxLength: CONTENT_MAX_LENGTH, ...input, ...rest }
|
||||
: { className: inputClasses, id, type, ...input, ...rest };
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export { default as EditListingWizard } from './EditListingWizard/EditListingWiz
|
|||
export { default as ExpandingTextarea } from './ExpandingTextarea/ExpandingTextarea';
|
||||
export { default as ExternalLink } from './ExternalLink/ExternalLink';
|
||||
export { default as FilterPanel } from './FilterPanel/FilterPanel';
|
||||
export { default as FieldReviewRating } from './FieldReviewRating/FieldReviewRating';
|
||||
export { default as Footer } from './Footer/Footer';
|
||||
export { default as Form } from './Form/Form';
|
||||
export { default as IconBannedUser } from './IconBannedUser/IconBannedUser';
|
||||
|
|
@ -38,6 +39,7 @@ export { default as IconEmailSuccess } from './IconEmailSuccess/IconEmailSuccess
|
|||
export { default as IconKeys } from './IconKeys/IconKeys';
|
||||
export { default as IconKeysSuccess } from './IconKeysSuccess/IconKeysSuccess';
|
||||
export { default as IconReviewStar } from './IconReviewStar/IconReviewStar';
|
||||
export { default as IconReviewUser } from './IconReviewUser/IconReviewUser';
|
||||
export { default as IconSearch } from './IconSearch/IconSearch';
|
||||
export {
|
||||
default as IconSocialMediaFacebook,
|
||||
|
|
@ -81,6 +83,7 @@ export { default as PrivacyPolicy } from './PrivacyPolicy/PrivacyPolicy';
|
|||
export { default as Promised } from './Promised/Promised';
|
||||
export { default as ResponsiveImage } from './ResponsiveImage/ResponsiveImage';
|
||||
export { default as ReviewRating } from './ReviewRating/ReviewRating';
|
||||
export { default as ReviewModal } from './ReviewModal/ReviewModal';
|
||||
export { default as SaleDetailsPanel } from './SaleDetailsPanel/SaleDetailsPanel';
|
||||
export { default as SearchMap } from './SearchMap/SearchMap';
|
||||
export { default as SearchMapGroupLabel } from './SearchMapGroupLabel/SearchMapGroupLabel';
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ exports[`EditListingDescriptionForm matches snapshot 1`] = `
|
|||
<textarea
|
||||
className="undefined"
|
||||
id="fakeTestForm.description"
|
||||
maxLength={5000}
|
||||
name="description"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
|
|
@ -49,6 +50,7 @@ exports[`EditListingDescriptionForm matches snapshot 1`] = `
|
|||
onDrop={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="How many people can fit at once? Does the sauna come with towels?"
|
||||
rows={1}
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { pick } from 'lodash';
|
||||
import { types } from '../../util/sdkLoader';
|
||||
import { storableError } from '../../util/errors';
|
||||
import { isTransactionsTransitionInvalidTransition, storableError } from '../../util/errors';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck';
|
||||
import { updatedEntities, denormalisedEntities } from '../../util/data';
|
||||
|
||||
|
|
@ -22,6 +23,10 @@ export const SEND_MESSAGE_REQUEST = 'app/OrderPage/SEND_MESSAGE_REQUEST';
|
|||
export const SEND_MESSAGE_SUCCESS = 'app/OrderPage/SEND_MESSAGE_SUCCESS';
|
||||
export const SEND_MESSAGE_ERROR = 'app/OrderPage/SEND_MESSAGE_ERROR';
|
||||
|
||||
export const SEND_REVIEW_REQUEST = 'app/OrderPage/SEND_REVIEW_REQUEST';
|
||||
export const SEND_REVIEW_SUCCESS = 'app/OrderPage/SEND_REVIEW_SUCCESS';
|
||||
export const SEND_REVIEW_ERROR = 'app/OrderPage/SEND_REVIEW_ERROR';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const initialState = {
|
||||
|
|
@ -35,6 +40,8 @@ const initialState = {
|
|||
messageSendingFailedToTransaction: null,
|
||||
sendMessageInProgress: false,
|
||||
sendMessageError: null,
|
||||
sendReviewInProgress: false,
|
||||
sendReviewError: null,
|
||||
};
|
||||
|
||||
export default function checkoutPageReducer(state = initialState, action = {}) {
|
||||
|
|
@ -72,6 +79,13 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
|
|||
case SEND_MESSAGE_ERROR:
|
||||
return { ...state, sendMessageInProgress: false, sendMessageError: payload };
|
||||
|
||||
case SEND_REVIEW_REQUEST:
|
||||
return { ...state, sendReviewInProgress: true, sendReviewError: null };
|
||||
case SEND_REVIEW_SUCCESS:
|
||||
return { ...state, sendReviewInProgress: false };
|
||||
case SEND_REVIEW_ERROR:
|
||||
return { ...state, sendReviewInProgress: false, sendReviewError: payload };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -105,6 +119,10 @@ const sendMessageRequest = () => ({ type: SEND_MESSAGE_REQUEST });
|
|||
const sendMessageSuccess = () => ({ type: SEND_MESSAGE_SUCCESS });
|
||||
const sendMessageError = e => ({ type: SEND_MESSAGE_ERROR, error: true, payload: e });
|
||||
|
||||
const sendReviewRequest = () => ({ type: SEND_REVIEW_REQUEST });
|
||||
const sendReviewSuccess = () => ({ type: SEND_REVIEW_SUCCESS });
|
||||
const sendReviewError = e => ({ type: SEND_REVIEW_ERROR, error: true, payload: e });
|
||||
|
||||
// ================ Thunks ================ //
|
||||
|
||||
const listingRelationship = txResponse => {
|
||||
|
|
@ -200,21 +218,6 @@ export const fetchMoreMessages = txId => (dispatch, getState, sdk) => {
|
|||
return dispatch(fetchNLatestMessages(txId, messagesToFetch));
|
||||
};
|
||||
|
||||
// loadData is a collection of async calls that need to be made
|
||||
// before page has all the info it needs to render itself
|
||||
export const loadData = params => dispatch => {
|
||||
const orderId = new types.UUID(params.id);
|
||||
|
||||
// Clear the send error since the message form is emptied as well.
|
||||
dispatch(setInitialValues({ sendMessageError: null }));
|
||||
|
||||
// Order (i.e. transaction entity in API, but from buyers perspective) contains order details
|
||||
return Promise.all([
|
||||
dispatch(fetchOrder(orderId)),
|
||||
dispatch(fetchNLatestMessages(orderId, MESSAGES_PAGE_SIZE)),
|
||||
]);
|
||||
};
|
||||
|
||||
export const sendMessage = (orderId, message) => (dispatch, getState, sdk) => {
|
||||
dispatch(sendMessageRequest());
|
||||
|
||||
|
|
@ -243,3 +246,84 @@ export const sendMessage = (orderId, message) => (dispatch, getState, sdk) => {
|
|||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
const REVIEW_TX_INCLUDES = ['reviews', 'reviews.author', 'reviews.subject'];
|
||||
|
||||
// If other party (provider) has already sent a review, we need to make transition to
|
||||
// TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND
|
||||
const sendReviewAsSecond = (id, params, dispatch, sdk) => {
|
||||
const transition = propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_SECOND;
|
||||
const include = REVIEW_TX_INCLUDES;
|
||||
|
||||
return sdk.transactions
|
||||
.transition({ id, transition, params }, { expand: true, include })
|
||||
.then(response => {
|
||||
dispatch(addMarketplaceEntities(response));
|
||||
dispatch(sendReviewSuccess());
|
||||
return response;
|
||||
})
|
||||
.catch(e => {
|
||||
dispatch(sendReviewError(storableError(e)));
|
||||
|
||||
// Rethrow so the page can track whether the sending failed, and
|
||||
// keep the message in the form for a retry.
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
// If other party (provider) has not yet sent a review, we need to make transition to
|
||||
// TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST
|
||||
// However, the other party might have made the review after previous data synch point.
|
||||
// So, error is likely to happen and then we must try another state transition
|
||||
// by calling sendReviewAsSecond().
|
||||
const sendReviewAsFirst = (id, params, dispatch, sdk) => {
|
||||
const transition = propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST;
|
||||
const include = REVIEW_TX_INCLUDES;
|
||||
|
||||
return sdk.transactions
|
||||
.transition({ id, transition, params }, { expand: true, include })
|
||||
.then(response => {
|
||||
dispatch(addMarketplaceEntities(response));
|
||||
dispatch(sendReviewSuccess());
|
||||
return response;
|
||||
})
|
||||
.catch(e => {
|
||||
// If transaction transition is invalid, lets try another endpoint.
|
||||
if (isTransactionsTransitionInvalidTransition(e)) {
|
||||
return sendReviewAsSecond(id, params, dispatch, sdk);
|
||||
} else {
|
||||
dispatch(sendReviewError(storableError(e)));
|
||||
|
||||
// Rethrow so the page can track whether the sending failed, and
|
||||
// keep the message in the form for a retry.
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const sendReview = (tx, reviewRating, reviewContent) => (dispatch, getState, sdk) => {
|
||||
const params = { reviewRating, reviewContent };
|
||||
const txStateProviderFirst =
|
||||
tx.attributes.lastTransition === propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST;
|
||||
|
||||
dispatch(sendReviewRequest());
|
||||
|
||||
return txStateProviderFirst
|
||||
? sendReviewAsSecond(tx.id, params, dispatch, sdk)
|
||||
: sendReviewAsFirst(tx.id, params, dispatch, sdk);
|
||||
};
|
||||
|
||||
// loadData is a collection of async calls that need to be made
|
||||
// before page has all the info it needs to render itself
|
||||
export const loadData = params => dispatch => {
|
||||
const orderId = new types.UUID(params.id);
|
||||
|
||||
// Clear the send error since the message form is emptied as well.
|
||||
dispatch(setInitialValues({ sendMessageError: null, sendReviewError: null }));
|
||||
|
||||
// Order (i.e. transaction entity in API, but from buyers perspective) contains order details
|
||||
return Promise.all([
|
||||
dispatch(fetchOrder(orderId)),
|
||||
dispatch(fetchNLatestMessages(orderId, MESSAGES_PAGE_SIZE)),
|
||||
]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
|
|||
import * as propTypes from '../../util/propTypes';
|
||||
import { ensureListing, ensureTransaction } from '../../util/data';
|
||||
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
|
||||
import { isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { isScrollingDisabled, manageDisableScrolling } from '../../ducks/UI.duck';
|
||||
import {
|
||||
NamedRedirect,
|
||||
OrderDetailsPanel,
|
||||
|
|
@ -21,7 +21,13 @@ import {
|
|||
} from '../../components';
|
||||
import { TopbarContainer } from '../../containers';
|
||||
|
||||
import { loadData, setInitialValues, sendMessage, fetchMoreMessages } from './OrderPage.duck';
|
||||
import {
|
||||
loadData,
|
||||
setInitialValues,
|
||||
sendMessage,
|
||||
sendReview,
|
||||
fetchMoreMessages,
|
||||
} from './OrderPage.duck';
|
||||
import css from './OrderPage.css';
|
||||
|
||||
// OrderPage handles data loading
|
||||
|
|
@ -37,8 +43,12 @@ export const OrderPageComponent = props => {
|
|||
messageSendingFailedToTransaction,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
onManageDisableScrolling,
|
||||
onShowMoreMessages,
|
||||
onSendMessage,
|
||||
onSendReview,
|
||||
onResetForm,
|
||||
intl,
|
||||
params,
|
||||
|
|
@ -96,8 +106,12 @@ export const OrderPageComponent = props => {
|
|||
fetchMessagesError={fetchMessagesError}
|
||||
sendMessageInProgress={sendMessageInProgress}
|
||||
sendMessageError={sendMessageError}
|
||||
sendReviewInProgress={sendReviewInProgress}
|
||||
sendReviewError={sendReviewError}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
onShowMoreMessages={onShowMoreMessages}
|
||||
onSendMessage={onSendMessage}
|
||||
onSendReview={onSendReview}
|
||||
onResetForm={onResetForm}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -168,6 +182,8 @@ const mapStateToProps = state => {
|
|||
messageSendingFailedToTransaction,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
} = state.OrderPage;
|
||||
const transactions = getMarketplaceEntities(state, transactionRef ? [transactionRef] : []);
|
||||
const transaction = transactions.length > 0 ? transactions[0] : null;
|
||||
|
|
@ -182,14 +198,20 @@ const mapStateToProps = state => {
|
|||
messageSendingFailedToTransaction,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
scrollingDisabled: isScrollingDisabled(state),
|
||||
transaction,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onManageDisableScrolling: (componentId, disableScrolling) =>
|
||||
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
||||
onShowMoreMessages: orderId => dispatch(fetchMoreMessages(orderId)),
|
||||
onSendMessage: (orderId, message) => dispatch(sendMessage(orderId, message)),
|
||||
onSendReview: (tx, reviewRating, reviewContent) =>
|
||||
dispatch(sendReview(tx, reviewRating, reviewContent)),
|
||||
onResetForm: formName => dispatch(resetForm(formName)),
|
||||
});
|
||||
|
||||
|
|
|
|||
53
src/containers/ReviewForm/ReviewForm.css
Normal file
53
src/containers/ReviewForm/ReviewForm.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Layout: size and positioning */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--failColor);
|
||||
}
|
||||
|
||||
.errorPlaceholder {
|
||||
@media (--viewportMedium) {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.reviewRating {
|
||||
margin-bottom: 18px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.reviewContent {
|
||||
flex-shrink: 0;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
margin-top: auto;
|
||||
margin-bottom: 96px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-top: 36px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
14
src/containers/ReviewForm/ReviewForm.example.js
Normal file
14
src/containers/ReviewForm/ReviewForm.example.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* eslint-disable no-console */
|
||||
import ReviewForm from './ReviewForm';
|
||||
|
||||
export const Empty = {
|
||||
component: ReviewForm,
|
||||
props: {
|
||||
onSubmit: values => {
|
||||
console.log('Submit ReviewForm with (unformatted) values:', values);
|
||||
},
|
||||
reviewSent: false,
|
||||
sendReviewInProgress: false,
|
||||
},
|
||||
group: 'forms',
|
||||
};
|
||||
112
src/containers/ReviewForm/ReviewForm.js
Normal file
112
src/containers/ReviewForm/ReviewForm.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { compose } from 'redux';
|
||||
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
|
||||
import { intlShape, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { isTransactionsTransitionAlreadyReviewed } from '../../util/errors';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import { required } from '../../util/validators';
|
||||
import { FieldReviewRating, Form, PrimaryButton, TextInputField } from '../../components';
|
||||
|
||||
import css from './ReviewForm.css';
|
||||
|
||||
const ReviewFormComponent = props => {
|
||||
const {
|
||||
className,
|
||||
rootClassName,
|
||||
disabled,
|
||||
handleSubmit,
|
||||
intl,
|
||||
form,
|
||||
invalid,
|
||||
submitting,
|
||||
reviewSent,
|
||||
sendReviewError,
|
||||
sendReviewInProgress,
|
||||
} = props;
|
||||
|
||||
const reviewRating = intl.formatMessage({ id: 'ReviewForm.reviewRatingLabel' });
|
||||
const reviewRatingRequiredMessage = intl.formatMessage({
|
||||
id: 'ReviewForm.reviewRatingRequired',
|
||||
});
|
||||
|
||||
const reviewContent = intl.formatMessage({ id: 'ReviewForm.reviewContentLabel' });
|
||||
const reviewContentPlaceholderMessage = intl.formatMessage({
|
||||
id: 'ReviewForm.reviewContentPlaceholder',
|
||||
});
|
||||
const reviewContentRequiredMessage = intl.formatMessage({
|
||||
id: 'ReviewForm.reviewContentRequired',
|
||||
});
|
||||
|
||||
const errorMessage = isTransactionsTransitionAlreadyReviewed(sendReviewError) ? (
|
||||
<p className={css.error}>
|
||||
<FormattedMessage id="ReviewForm.reviewSubmitAlreadySent" />
|
||||
</p>
|
||||
) : (
|
||||
<p className={css.error}>
|
||||
<FormattedMessage id="ReviewForm.reviewSubmitFailed" />
|
||||
</p>
|
||||
);
|
||||
const errorArea = sendReviewError ? errorMessage : <p className={css.errorPlaceholder} />;
|
||||
|
||||
const reviewSubmitMessage = intl.formatMessage({
|
||||
id: 'ReviewForm.reviewSubmit',
|
||||
});
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const submitInProgress = submitting || sendReviewInProgress;
|
||||
const submitDisabled = invalid || disabled || submitInProgress;
|
||||
|
||||
return (
|
||||
<Form className={classes} onSubmit={handleSubmit}>
|
||||
<FieldReviewRating
|
||||
className={css.reviewRating}
|
||||
id={`${form}.starRating`}
|
||||
name="reviewRating"
|
||||
label={reviewRating}
|
||||
validate={required(reviewRatingRequiredMessage)}
|
||||
/>
|
||||
|
||||
<TextInputField
|
||||
className={css.reviewContent}
|
||||
type="textarea"
|
||||
name="reviewContent"
|
||||
id={`${form}.reviewContent`}
|
||||
label={reviewContent}
|
||||
placeholder={reviewContentPlaceholderMessage}
|
||||
validate={[required(reviewContentRequiredMessage)]}
|
||||
/>
|
||||
|
||||
{errorArea}
|
||||
<PrimaryButton
|
||||
className={css.submitButton}
|
||||
type="submit"
|
||||
inProgress={submitInProgress}
|
||||
disabled={submitDisabled}
|
||||
ready={reviewSent}
|
||||
>
|
||||
{reviewSubmitMessage}
|
||||
</PrimaryButton>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
ReviewFormComponent.defaultProps = { className: null, rootClassName: null, sendReviewError: null };
|
||||
|
||||
const { bool, func, string } = PropTypes;
|
||||
|
||||
ReviewFormComponent.propTypes = {
|
||||
...formPropTypes,
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
intl: intlShape.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
reviewSent: bool.isRequired,
|
||||
sendReviewError: propTypes.error,
|
||||
sendReviewInProgress: bool.isRequired,
|
||||
};
|
||||
|
||||
const formName = 'ReviewForm';
|
||||
|
||||
export default compose(reduxForm({ form: formName }), injectIntl)(ReviewFormComponent);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { pick } from 'lodash';
|
||||
import { types } from '../../util/sdkLoader';
|
||||
import { storableError } from '../../util/errors';
|
||||
import { isTransactionsTransitionInvalidTransition, storableError } from '../../util/errors';
|
||||
import * as propTypes from '../../util/propTypes';
|
||||
import * as log from '../../util/log';
|
||||
import { updatedEntities, denormalisedEntities } from '../../util/data';
|
||||
|
|
@ -10,6 +11,8 @@ const MESSAGES_PAGE_SIZE = 100;
|
|||
|
||||
// ================ Action types ================ //
|
||||
|
||||
export const SET_INITAL_VALUES = 'app/OrderPage/SET_INITIAL_VALUES';
|
||||
|
||||
export const FETCH_SALE_REQUEST = 'app/SalePagePage/FETCH_SALE_REQUEST';
|
||||
export const FETCH_SALE_SUCCESS = 'app/SalePage/FETCH_SALE_SUCCESS';
|
||||
export const FETCH_SALE_ERROR = 'app/SalePage/FETCH_SALE_ERROR';
|
||||
|
|
@ -29,7 +32,10 @@ export const FETCH_MESSAGES_ERROR = 'app/SalePage/FETCH_MESSAGES_ERROR';
|
|||
export const SEND_MESSAGE_REQUEST = 'app/SalePage/SEND_MESSAGE_REQUEST';
|
||||
export const SEND_MESSAGE_SUCCESS = 'app/SalePage/SEND_MESSAGE_SUCCESS';
|
||||
export const SEND_MESSAGE_ERROR = 'app/SalePage/SEND_MESSAGE_ERROR';
|
||||
export const CLEAR_SEND_MESSAGE_ERROR = 'app/SalePage/CLEAR_SEND_MESSAGE_ERROR';
|
||||
|
||||
export const SEND_REVIEW_REQUEST = 'app/SalePage/SEND_REVIEW_REQUEST';
|
||||
export const SEND_REVIEW_SUCCESS = 'app/SalePage/SEND_REVIEW_SUCCESS';
|
||||
export const SEND_REVIEW_ERROR = 'app/SalePage/SEND_REVIEW_ERROR';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
|
|
@ -47,11 +53,16 @@ const initialState = {
|
|||
messages: [],
|
||||
sendMessageInProgress: false,
|
||||
sendMessageError: null,
|
||||
sendReviewInProgress: false,
|
||||
sendReviewError: null,
|
||||
};
|
||||
|
||||
export default function checkoutPageReducer(state = initialState, action = {}) {
|
||||
const { type, payload } = action;
|
||||
switch (type) {
|
||||
case SET_INITAL_VALUES:
|
||||
return { ...initialState, ...payload };
|
||||
|
||||
case FETCH_SALE_REQUEST:
|
||||
return { ...state, fetchSaleInProgress: true, fetchSaleError: null };
|
||||
case FETCH_SALE_SUCCESS: {
|
||||
|
|
@ -94,8 +105,13 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
|
|||
return { ...state, sendMessageInProgress: false };
|
||||
case SEND_MESSAGE_ERROR:
|
||||
return { ...state, sendMessageInProgress: false, sendMessageError: payload };
|
||||
case CLEAR_SEND_MESSAGE_ERROR:
|
||||
return { ...state, sendMessageError: null };
|
||||
|
||||
case SEND_REVIEW_REQUEST:
|
||||
return { ...state, sendReviewInProgress: true, sendReviewError: null };
|
||||
case SEND_REVIEW_SUCCESS:
|
||||
return { ...state, sendReviewInProgress: false };
|
||||
case SEND_REVIEW_ERROR:
|
||||
return { ...state, sendReviewInProgress: false, sendReviewError: payload };
|
||||
|
||||
default:
|
||||
return state;
|
||||
|
|
@ -113,6 +129,10 @@ export const fetchedMessagesCount = state => {
|
|||
};
|
||||
|
||||
// ================ Action creators ================ //
|
||||
export const setInitialValues = initialValues => ({
|
||||
type: SET_INITAL_VALUES,
|
||||
payload: pick(initialValues, Object.keys(initialState)),
|
||||
});
|
||||
|
||||
const fetchSaleRequest = () => ({ type: FETCH_SALE_REQUEST });
|
||||
const fetchSaleSuccess = response => ({ type: FETCH_SALE_SUCCESS, payload: response });
|
||||
|
|
@ -136,7 +156,10 @@ const fetchMessagesError = e => ({ type: FETCH_MESSAGES_ERROR, error: true, payl
|
|||
const sendMessageRequest = () => ({ type: SEND_MESSAGE_REQUEST });
|
||||
const sendMessageSuccess = () => ({ type: SEND_MESSAGE_SUCCESS });
|
||||
const sendMessageError = e => ({ type: SEND_MESSAGE_ERROR, error: true, payload: e });
|
||||
const clearSendMessageError = () => ({ type: CLEAR_SEND_MESSAGE_ERROR });
|
||||
|
||||
const sendReviewRequest = () => ({ type: SEND_REVIEW_REQUEST });
|
||||
const sendReviewSuccess = () => ({ type: SEND_REVIEW_SUCCESS });
|
||||
const sendReviewError = e => ({ type: SEND_REVIEW_ERROR, error: true, payload: e });
|
||||
|
||||
// ================ Thunks ================ //
|
||||
|
||||
|
|
@ -271,21 +294,6 @@ export const fetchMoreMessages = txId => (dispatch, getState, sdk) => {
|
|||
return dispatch(fetchNLatestMessages(txId, messagesToFetch));
|
||||
};
|
||||
|
||||
// loadData is a collection of async calls that need to be made
|
||||
// before page has all the info it needs to render itself
|
||||
export const loadData = params => dispatch => {
|
||||
const saleId = new types.UUID(params.id);
|
||||
|
||||
// Clear the send error since the message form is emptied as well.
|
||||
dispatch(clearSendMessageError());
|
||||
|
||||
// Sale (i.e. transaction entity in API, but from buyers perspective) contains sale details
|
||||
return Promise.all([
|
||||
dispatch(fetchSale(saleId)),
|
||||
dispatch(fetchNLatestMessages(saleId, MESSAGES_PAGE_SIZE)),
|
||||
]);
|
||||
};
|
||||
|
||||
export const sendMessage = (saleId, message) => (dispatch, getState, sdk) => {
|
||||
dispatch(sendMessageRequest());
|
||||
|
||||
|
|
@ -307,3 +315,84 @@ export const sendMessage = (saleId, message) => (dispatch, getState, sdk) => {
|
|||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
const REVIEW_TX_INCLUDES = ['reviews', 'reviews.author', 'reviews.subject'];
|
||||
|
||||
// If other party (customer) has already sent a review, we need to make transition to
|
||||
// TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND
|
||||
const sendReviewAsSecond = (id, params, dispatch, sdk) => {
|
||||
const transition = propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND;
|
||||
const include = REVIEW_TX_INCLUDES;
|
||||
|
||||
return sdk.transactions
|
||||
.transition({ id, transition, params }, { expand: true, include })
|
||||
.then(response => {
|
||||
dispatch(addMarketplaceEntities(response));
|
||||
dispatch(sendReviewSuccess());
|
||||
return response;
|
||||
})
|
||||
.catch(e => {
|
||||
dispatch(sendReviewError(storableError(e)));
|
||||
|
||||
// Rethrow so the page can track whether the sending failed, and
|
||||
// keep the message in the form for a retry.
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
// If other party (customer) has not yet sent a review, we need to make transition to
|
||||
// TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST
|
||||
// However, the other party might have made the review after previous data synch point.
|
||||
// So, error is likely to happen and then we must try another state transition
|
||||
// by calling sendReviewAsSecond().
|
||||
const sendReviewAsFirst = (id, params, dispatch, sdk) => {
|
||||
const transition = propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST;
|
||||
const include = REVIEW_TX_INCLUDES;
|
||||
|
||||
return sdk.transactions
|
||||
.transition({ id, transition, params }, { expand: true, include })
|
||||
.then(response => {
|
||||
dispatch(addMarketplaceEntities(response));
|
||||
dispatch(sendReviewSuccess());
|
||||
return response;
|
||||
})
|
||||
.catch(e => {
|
||||
// If transaction transition is invalid, lets try another endpoint.
|
||||
if (isTransactionsTransitionInvalidTransition(e)) {
|
||||
return sendReviewAsSecond(id, params, dispatch, sdk);
|
||||
} else {
|
||||
dispatch(sendReviewError(storableError(e)));
|
||||
|
||||
// Rethrow so the page can track whether the sending failed, and
|
||||
// keep the message in the form for a retry.
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const sendReview = (tx, reviewRating, reviewContent) => (dispatch, getState, sdk) => {
|
||||
const params = { reviewRating, reviewContent };
|
||||
const txStateProviderFirst =
|
||||
tx.attributes.lastTransition === propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST;
|
||||
|
||||
dispatch(sendReviewRequest());
|
||||
|
||||
return txStateProviderFirst
|
||||
? sendReviewAsSecond(tx.id, params, dispatch, sdk)
|
||||
: sendReviewAsFirst(tx.id, params, dispatch, sdk);
|
||||
};
|
||||
|
||||
// loadData is a collection of async calls that need to be made
|
||||
// before page has all the info it needs to render itself
|
||||
export const loadData = params => dispatch => {
|
||||
const saleId = new types.UUID(params.id);
|
||||
|
||||
// Clear the send error since the message form is emptied as well.
|
||||
dispatch(setInitialValues({ sendMessageError: null, sendReviewError: null }));
|
||||
|
||||
// Sale (i.e. transaction entity in API, but from buyers perspective) contains sale details
|
||||
return Promise.all([
|
||||
dispatch(fetchSale(saleId)),
|
||||
dispatch(fetchNLatestMessages(saleId, MESSAGES_PAGE_SIZE)),
|
||||
]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import { reset as resetForm } from 'redux-form';
|
|||
import * as propTypes from '../../util/propTypes';
|
||||
import { ensureListing, ensureTransaction } from '../../util/data';
|
||||
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
|
||||
import { isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { acceptSale, declineSale, loadData, sendMessage, fetchMoreMessages } from './SalePage.duck';
|
||||
import { isScrollingDisabled, manageDisableScrolling } from '../../ducks/UI.duck';
|
||||
import {
|
||||
NamedRedirect,
|
||||
SaleDetailsPanel,
|
||||
|
|
@ -22,6 +21,14 @@ import {
|
|||
} from '../../components';
|
||||
import { TopbarContainer } from '../../containers';
|
||||
|
||||
import {
|
||||
acceptSale,
|
||||
declineSale,
|
||||
loadData,
|
||||
sendMessage,
|
||||
sendReview,
|
||||
fetchMoreMessages,
|
||||
} from './SalePage.duck';
|
||||
import css from './SalePage.css';
|
||||
|
||||
// SalePage handles data loading
|
||||
|
|
@ -46,8 +53,12 @@ export const SalePageComponent = props => {
|
|||
messages,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
onManageDisableScrolling,
|
||||
onShowMoreMessages,
|
||||
onSendMessage,
|
||||
onSendReview,
|
||||
onResetForm,
|
||||
} = props;
|
||||
|
||||
|
|
@ -100,8 +111,12 @@ export const SalePageComponent = props => {
|
|||
fetchMessagesError={fetchMessagesError}
|
||||
sendMessageInProgress={sendMessageInProgress}
|
||||
sendMessageError={sendMessageError}
|
||||
sendReviewInProgress={sendReviewInProgress}
|
||||
sendReviewError={sendReviewError}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
onShowMoreMessages={onShowMoreMessages}
|
||||
onSendMessage={onSendMessage}
|
||||
onSendReview={onSendReview}
|
||||
onResetForm={onResetForm}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -181,6 +196,8 @@ const mapStateToProps = state => {
|
|||
messages,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
} = state.SalePage;
|
||||
const { currentUser } = state.user;
|
||||
|
||||
|
|
@ -202,6 +219,8 @@ const mapStateToProps = state => {
|
|||
messages,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
sendReviewError,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -212,6 +231,10 @@ const mapDispatchToProps = dispatch => {
|
|||
onShowMoreMessages: saleId => dispatch(fetchMoreMessages(saleId)),
|
||||
onSendMessage: (saleId, message) => dispatch(sendMessage(saleId, message)),
|
||||
onResetForm: formName => dispatch(resetForm(formName)),
|
||||
onManageDisableScrolling: (componentId, disableScrolling) =>
|
||||
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
||||
onSendReview: (tx, reviewRating, reviewContent) =>
|
||||
dispatch(sendReview(tx, reviewRating, reviewContent)),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export { default as PrivacyPolicyPage } from './PrivacyPolicyPage/PrivacyPolicyP
|
|||
export { default as ProfilePage } from './ProfilePage/ProfilePage';
|
||||
export { default as ProfileSettingsForm } from './ProfileSettingsForm/ProfileSettingsForm';
|
||||
export { default as ProfileSettingsPage } from './ProfileSettingsPage/ProfileSettingsPage';
|
||||
export { default as ReviewForm } from './ReviewForm/ReviewForm';
|
||||
export { default as SalePage } from './SalePage/SalePage';
|
||||
export { default as SearchPage } from './SearchPage/SearchPage';
|
||||
export { default as SendMessageForm } from './SendMessageForm/SendMessageForm';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import * as DateInputField from './components/DateInputField/DateInputField.exam
|
|||
import * as DateRangeInputField from './components/DateRangeInputField/DateRangeInputField.example';
|
||||
import * as EditListingWizard from './components/EditListingWizard/EditListingWizard.example';
|
||||
import * as ExpandingTextarea from './components/ExpandingTextarea/ExpandingTextarea.example';
|
||||
import * as FieldReviewRating from './components/FieldReviewRating/FieldReviewRating.example';
|
||||
import * as Footer from './components/Footer/Footer.example';
|
||||
import * as IconBannedUser from './components/IconBannedUser/IconBannedUser.example';
|
||||
import * as IconCheckmark from './components/IconCheckmark/IconCheckmark.example';
|
||||
|
|
@ -21,6 +22,7 @@ import * as IconKeys from './components/IconKeys/IconKeys.example';
|
|||
import * as IconKeysSuccess from './components/IconKeysSuccess/IconKeysSuccess.example';
|
||||
import * as IconSearch from './components/IconSearch/IconSearch.example';
|
||||
import * as IconReviewStar from './components/IconReviewStar/IconReviewStar.example';
|
||||
import * as IconReviewUser from './components/IconReviewUser/IconReviewUser.example';
|
||||
import * as IconSocialMediaFacebook from './components/IconSocialMediaFacebook/IconSocialMediaFacebook.example';
|
||||
import * as IconSocialMediaInstagram from './components/IconSocialMediaInstagram/IconSocialMediaInstagram.example';
|
||||
import * as IconSocialMediaTwitter from './components/IconSocialMediaTwitter/IconSocialMediaTwitter.example';
|
||||
|
|
@ -57,6 +59,7 @@ import * as LoginForm from './containers/LoginForm/LoginForm.example';
|
|||
import * as PasswordRecoveryForm from './containers/PasswordRecoveryForm/PasswordRecoveryForm.example';
|
||||
import * as PasswordResetForm from './containers/PasswordResetForm/PasswordResetForm.example';
|
||||
import * as PayoutDetailsForm from './containers/PayoutDetailsForm/PayoutDetailsForm.example';
|
||||
import * as ReviewForm from './containers/ReviewForm/ReviewForm.example';
|
||||
import * as SendMessageForm from './containers/SendMessageForm/SendMessageForm.example';
|
||||
import * as SignupForm from './containers/SignupForm/SignupForm.example';
|
||||
import * as StripePaymentForm from './containers/StripePaymentForm/StripePaymentForm.example';
|
||||
|
|
@ -81,6 +84,7 @@ export {
|
|||
EditListingWizard,
|
||||
EmailVerificationForm,
|
||||
ExpandingTextarea,
|
||||
FieldReviewRating,
|
||||
Footer,
|
||||
IconBannedUser,
|
||||
IconCheckmark,
|
||||
|
|
@ -91,6 +95,7 @@ export {
|
|||
IconKeys,
|
||||
IconKeysSuccess,
|
||||
IconReviewStar,
|
||||
IconReviewUser,
|
||||
IconSearch,
|
||||
IconSocialMediaFacebook,
|
||||
IconSocialMediaInstagram,
|
||||
|
|
@ -112,6 +117,7 @@ export {
|
|||
PayoutDetailsForm,
|
||||
ResponsiveImage,
|
||||
ReviewRating,
|
||||
ReviewForm,
|
||||
SelectField,
|
||||
SendMessageForm,
|
||||
SignupForm,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ ul {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
legend,
|
||||
label {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
font-weight: var(--fontWeightSemiBold);
|
||||
|
|
|
|||
|
|
@ -169,6 +169,11 @@
|
|||
"EmailVerificationForm.verifying": "Verifying…",
|
||||
"EmailVerificationPage.loadingUserInformation": "Loading user information…",
|
||||
"EmailVerificationPage.title": "Verify your email address",
|
||||
"FieldReviewRating.star1": "Bad experience - 1 star",
|
||||
"FieldReviewRating.star2": "Not so nice - 2 stars",
|
||||
"FieldReviewRating.star3": "OK - 3 stars",
|
||||
"FieldReviewRating.star4": "Good - 4 stars",
|
||||
"FieldReviewRating.star5": "Awesome - 5 stars",
|
||||
"Footer.copyright": "© Sharetribe 2017",
|
||||
"Footer.goToFacebook": "Go to Facebook page",
|
||||
"Footer.goToInstagram": "Go to Instagram page",
|
||||
|
|
@ -437,6 +442,17 @@
|
|||
"ProfileSettingsPage.title": "Profile settings",
|
||||
"ProfileSettingsPage.viewProfileLink": "View your profile",
|
||||
"ResponsiveImage.noImage": "No image",
|
||||
"ReviewForm.reviewContentLabel": "Leave a review",
|
||||
"ReviewForm.reviewContentPlaceholder": "Descripe your experience...",
|
||||
"ReviewForm.reviewContentRequired": "Review message is required",
|
||||
"ReviewForm.reviewRatingLabel": "Rate your experience",
|
||||
"ReviewForm.reviewRatingRequired": "Review message is required",
|
||||
"ReviewForm.reviewSubmit": "Publish review",
|
||||
"ReviewForm.reviewSubmitAlreadySent": "Review already sent. Please refresh the page.",
|
||||
"ReviewForm.reviewSubmitFailed": "Failed to sent a review. Please try again.",
|
||||
"ReviewModal.description": "Reviews are an important part of the Saunatime community. Please share what went well and what could have been improved.",
|
||||
"ReviewModal.later": "Later",
|
||||
"ReviewModal.title": "Leave a review for {revieweeName}",
|
||||
"SaleDetailsPanel.acceptSaleFailed": "Oops, accepting failed. Please try again.",
|
||||
"SaleDetailsPanel.activityHeading": "Activity",
|
||||
"SaleDetailsPanel.bannedUserDisplayName": "Banned user",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
|
||||
import {
|
||||
ERROR_CODE_TRANSACTION_LISTING_NOT_FOUND,
|
||||
ERROR_CODE_TRANSACTION_INVALID_TRANSITION,
|
||||
ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER,
|
||||
ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER,
|
||||
ERROR_CODE_PAYMENT_FAILED,
|
||||
ERROR_CODE_EMAIL_TAKEN,
|
||||
ERROR_CODE_EMAIL_NOT_FOUND,
|
||||
|
|
@ -123,6 +126,25 @@ export const isTransactionInitiateAmountTooLowError = error => {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given API error (from `sdk.transactions.transition(id, transition, params)`)
|
||||
* is due to invalid transition attempt.
|
||||
*/
|
||||
export const isTransactionsTransitionInvalidTransition = error =>
|
||||
error &&
|
||||
error.status === 409 &&
|
||||
hasErrorWithCode(error, ERROR_CODE_TRANSACTION_INVALID_TRANSITION);
|
||||
|
||||
/**
|
||||
* Check if the given API error (from `sdk.transactions.transition(id, transition, params)`)
|
||||
* is due to already sent review.
|
||||
*/
|
||||
export const isTransactionsTransitionAlreadyReviewed = error =>
|
||||
error &&
|
||||
error.status === 409 &&
|
||||
(hasErrorWithCode(error, ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER) ||
|
||||
hasErrorWithCode(error, ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER));
|
||||
|
||||
/**
|
||||
* Check if the given API error (from `sdk.currentUser.changeEmail(params)`)
|
||||
* is due to giving wrong password.
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ export const TX_TRANSITION_CANCEL = 'transition/cancel';
|
|||
export const TX_TRANSITION_MARK_DELIVERED = 'transition/mark-delivered';
|
||||
|
||||
// Review transitions
|
||||
// Reviews are given through transaction transitions.
|
||||
// Either party (provider or customer) can be the first to give a review.
|
||||
export const TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST = 'transition/review-by-provider-first';
|
||||
export const TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND = 'transition/review-by-provider-second';
|
||||
export const TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST = 'transition/review-by-customer-first';
|
||||
|
|
@ -312,6 +314,11 @@ export const pagination = shape({
|
|||
});
|
||||
|
||||
export const ERROR_CODE_TRANSACTION_LISTING_NOT_FOUND = 'transaction-listing-not-found';
|
||||
export const ERROR_CODE_TRANSACTION_INVALID_TRANSITION = 'transaction-invalid-transition';
|
||||
export const ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER =
|
||||
'transaction-already-reviewed-by-customer';
|
||||
export const ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER =
|
||||
'transaction-already-reviewed-by-provider';
|
||||
export const ERROR_CODE_PAYMENT_FAILED = 'transaction-payment-failed';
|
||||
export const ERROR_CODE_EMAIL_TAKEN = 'email-taken';
|
||||
export const ERROR_CODE_EMAIL_NOT_FOUND = 'email-not-found';
|
||||
|
|
@ -320,6 +327,9 @@ export const ERROR_CODE_TOO_MANY_VERIFICATION_REQUESTS = 'email-too-many-verific
|
|||
export const ERROR_CODE_UPLOAD_OVER_LIMIT = 'request-upload-over-limit';
|
||||
const ERROR_CODES = [
|
||||
ERROR_CODE_TRANSACTION_LISTING_NOT_FOUND,
|
||||
ERROR_CODE_TRANSACTION_INVALID_TRANSITION,
|
||||
ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_CUSTOMER,
|
||||
ERROR_CODE_TRANSACTION_ALREADY_REVIEWED_BY_PROVIDER,
|
||||
ERROR_CODE_PAYMENT_FAILED,
|
||||
ERROR_CODE_EMAIL_TAKEN,
|
||||
ERROR_CODE_EMAIL_NOT_FOUND,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue