<% const link = `
${ob.polyT('purchase.pendingSection.note1Link')}`; %>
<%= ob.polyT('purchase.pendingSection.note1', { link }) %>
- <% if (ob.moderator) { %>
+ <% if (ob.isModerated) { %>
<%= ob.polyT('purchase.pendingSection.note2') %>
diff --git a/js/utils/order.js b/js/utils/order.js
index 745a02d73..3f7f7136c 100644
--- a/js/utils/order.js
+++ b/js/utils/order.js
@@ -4,6 +4,8 @@ import { Events } from 'backbone';
import OrderFulfillment from '../models/order/orderFulfillment/OrderFulfillment';
import { openSimpleMessage } from '../views/modals/SimpleMessage';
import OrderCompletion from '../models/order/orderCompletion/OrderCompletion';
+import OrderDispute from '../models/order/OrderDispute';
+import ResolveDispute from '../models/order/ResolveDispute';
const events = {
...Events,
@@ -15,6 +17,9 @@ const cancelPosts = {};
const fulfillPosts = {};
const refundPosts = {};
const completePosts = {};
+const openDisputePosts = {};
+const resolvePosts = {};
+const acceptPayoutPosts = {};
function confirmOrder(orderId, reject = false) {
if (!orderId) {
@@ -148,7 +153,7 @@ export function fulfillingOrder(orderId) {
return fulfillPosts[orderId] || false;
}
-export function fulfillOrder(contractType = 'PHYSICAL_GOOD', data = {}) {
+export function fulfillOrder(contractType = 'PHYSICAL_GOOD', isLocalPickup = false, data = {}) {
if (!data || !data.orderId) {
throw new Error('An orderId must be provided with the data.');
}
@@ -158,7 +163,7 @@ export function fulfillOrder(contractType = 'PHYSICAL_GOOD', data = {}) {
let post = fulfillPosts[orderId];
if (!post) {
- const model = new OrderFulfillment(data, { contractType });
+ const model = new OrderFulfillment(data, { contractType, isLocalPickup });
post = model.save();
if (!post) {
@@ -251,7 +256,7 @@ export function refundOrder(orderId) {
/**
* If the order with the given id is in the process of being completed, this method
- * will return an object containing the post xhr and the model that's being save.
+ * will return an object containing the post xhr and the data that's being saved.
*/
export function completingOrder(orderId) {
return completePosts[orderId] || false;
@@ -262,9 +267,7 @@ export function completeOrder(orderId, data = {}) {
throw new Error('Please provide an orderId');
}
- const completeObject = completePosts[orderId];
-
- if (!completeObject) {
+ if (!completePosts[orderId]) {
const model = new OrderCompletion(data);
const save = model.save();
@@ -297,7 +300,7 @@ export function completeOrder(orderId, data = {}) {
completePosts[orderId] = {
xhr: save,
- model,
+ data: model.toJSON(),
};
}
@@ -309,3 +312,173 @@ export function completeOrder(orderId, data = {}) {
return completePosts[orderId].xhr;
}
+
+/**
+ * If the order with the given id is in the process of a dispute being opened,
+ * this method will return an object containing the post xhr and the data
+ * that's being saved.
+ */
+export function openingDispute(orderId) {
+ return openDisputePosts[orderId] || false;
+}
+
+export function openDispute(orderId, data = {}) {
+ if (!orderId) {
+ throw new Error('Please provide an orderId');
+ }
+
+ if (!openDisputePosts[orderId]) {
+ const model = new OrderDispute(data);
+ const save = model.save();
+
+ if (!save) {
+ Object.keys(model.validationError)
+ .forEach(errorKey => {
+ throw new Error(`${errorKey}: ${model.validationError[errorKey][0]}`);
+ });
+ } else {
+ save.always(() => {
+ delete openDisputePosts[orderId];
+ }).done(() => {
+ events.trigger('openDisputeComplete', {
+ id: orderId,
+ xhr: save,
+ });
+ })
+ .fail(xhr => {
+ events.trigger('openDisputeFail', {
+ id: orderId,
+ xhr: save,
+ });
+
+ const failReason = xhr.responseJSON && xhr.responseJSON.reason || '';
+ openSimpleMessage(
+ app.polyglot.t('orderUtil.failedOpenDisputeHeading'),
+ failReason
+ );
+ });
+
+ openDisputePosts[orderId] = {
+ xhr: save,
+ data: model.toJSON(),
+ };
+ }
+
+ events.trigger('openingDisputeOrder', {
+ id: orderId,
+ xhr: save,
+ });
+ }
+
+ return openDisputePosts[orderId].xhr;
+}
+
+/**
+ * If the order with the given id is in the process of its dispute being resolved,
+ * this method will return an object containing the post xhr and the data that's
+ * being saved.
+ */
+export function resolvingDispute(orderId) {
+ return resolvePosts[orderId] || false;
+}
+
+export function resolveDispute(orderId, data = {}) {
+ if (!orderId) {
+ throw new Error('Please provide an orderId');
+ }
+
+ if (!resolvePosts[orderId]) {
+ const model = new ResolveDispute(data);
+ const save = model.save();
+
+ if (!save) {
+ Object.keys(model.validationError)
+ .forEach(errorKey => {
+ throw new Error(`${errorKey}: ${model.validationError[errorKey][0]}`);
+ });
+ } else {
+ save.always(() => {
+ delete resolvePosts[orderId];
+ }).done(() => {
+ events.trigger('resolveDisputeComplete', {
+ id: orderId,
+ xhr: save,
+ });
+ })
+ .fail(xhr => {
+ events.trigger('resolveDisputeFail', {
+ id: orderId,
+ xhr: save,
+ });
+
+ const failReason = xhr.responseJSON && xhr.responseJSON.reason || '';
+ openSimpleMessage(
+ app.polyglot.t('orderUtil.failedResolveHeading'),
+ failReason
+ );
+ });
+
+ resolvePosts[orderId] = {
+ xhr: save,
+ data: model.toJSON(),
+ };
+ }
+
+ events.trigger('resolvingDispute', {
+ id: orderId,
+ xhr: save,
+ });
+ }
+
+ return resolvePosts[orderId].xhr;
+}
+
+export function acceptingPayout(orderId) {
+ return acceptPayoutPosts[orderId] || false;
+}
+
+export function acceptPayout(orderId) {
+ if (!orderId) {
+ throw new Error('Please provide an orderId');
+ }
+
+ let post = acceptPayoutPosts[orderId];
+
+ if (!post) {
+ post = $.post({
+ url: app.getServerUrl('ob/releasefunds'),
+ data: JSON.stringify({
+ orderId,
+ }),
+ dataType: 'json',
+ contentType: 'application/json',
+ }).always(() => {
+ delete acceptPayoutPosts[orderId];
+ }).done(() => {
+ events.trigger('acceptPayoutComplete', {
+ id: orderId,
+ xhr: post,
+ });
+ })
+ .fail(xhr => {
+ events.trigger('acceptPayoutFail', {
+ id: orderId,
+ xhr: post,
+ });
+
+ const failReason = xhr.responseJSON && xhr.responseJSON.reason || '';
+ openSimpleMessage(
+ app.polyglot.t('orderUtil.failedAcceptPayoutHeading'),
+ failReason
+ );
+ });
+
+ acceptPayoutPosts[orderId] = post;
+ events.trigger('acceptingPayout', {
+ id: orderId,
+ xhr: post,
+ });
+ }
+
+ return post;
+}
diff --git a/js/views/modals/listingDetail/Listing.js b/js/views/modals/listingDetail/Listing.js
index 336b3bf73..a81a8cd25 100644
--- a/js/views/modals/listingDetail/Listing.js
+++ b/js/views/modals/listingDetail/Listing.js
@@ -312,7 +312,7 @@ export default class extends BaseModal {
this.totalPrice = _totalPrice;
const adjPrice = convertAndFormatCurrency(this.totalPrice,
this.model.get('metadata').get('pricingCurrency'), app.settings.get('localCurrency'));
- this.getCachedElement('.js-price').text(adjPrice);
+ this.getCachedEl('.js-price').text(adjPrice);
}
}
diff --git a/js/views/modals/orderDetail/ActionBar.js b/js/views/modals/orderDetail/ActionBar.js
new file mode 100644
index 000000000..b2894864d
--- /dev/null
+++ b/js/views/modals/orderDetail/ActionBar.js
@@ -0,0 +1,64 @@
+import _ from 'underscore';
+import loadTemplate from '../../../utils/loadTemplate';
+import BaseVw from '../../baseVw';
+
+export default class extends BaseVw {
+ constructor(options = {}) {
+ super(options);
+
+ if (!options.orderId) {
+ throw new Error('Please provide the order id.');
+ }
+
+ this.orderId = options.orderId;
+ this._state = {
+ showDisputeOrderButton: false,
+ ...options.initialState || {},
+ };
+ }
+
+ className() {
+ return 'actionBar gutterV';
+ }
+
+ events() {
+ return {
+ 'click .js-openDispute': 'onClickOpenDispute',
+ };
+ }
+
+ onClickOpenDispute() {
+ this.trigger('clickOpenDispute');
+ }
+
+ getState() {
+ return this._state;
+ }
+
+ setState(state, replace = false, renderOnChange = true) {
+ let newState;
+
+ if (replace) {
+ this._state = {};
+ } else {
+ newState = _.extend({}, this._state, state);
+ }
+
+ if (renderOnChange && !_.isEqual(this._state, newState)) {
+ this._state = newState;
+ this.render();
+ }
+
+ return this;
+ }
+
+ render() {
+ loadTemplate('modals/orderDetail/actionBar.html', (t) => {
+ this.$el.html(t({
+ ...this._state,
+ }));
+ });
+
+ return this;
+ }
+}
diff --git a/js/views/modals/orderDetail/ConvoMessages.js b/js/views/modals/orderDetail/ConvoMessages.js
index 4ab1f8a58..a2ae34e4f 100644
--- a/js/views/modals/orderDetail/ConvoMessages.js
+++ b/js/views/modals/orderDetail/ConvoMessages.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import app from '../../../app';
+import { checkValidParticipantObject } from './OrderDetail.js';
import BaseVw from '../../baseVw';
import ConvoMessage from './ConvoMessage';
@@ -13,36 +14,11 @@ export default class extends BaseVw {
throw new Error('Please provide the DOM element that handles scrolling for this view.');
}
- const isValidParticipantObject = (participant) => {
- let isValid = true;
- if (!participant.id) isValid = false;
- if (typeof participant.getProfile !== 'function') isValid = false;
- return isValid;
- };
+ checkValidParticipantObject(options.buyer, 'buyer');
+ checkValidParticipantObject(options.vendor, 'vendor');
- const getInvalidParticpantError = (type = '') =>
- (`The ${type} object is not valid. It should have an id ` +
- 'as well as a getProfile function that returns a promise that ' +
- 'resolves with a profile model.');
-
- if (!options.buyer) {
- throw new Error('Please provide a buyer object.');
- }
-
- if (!options.vendor) {
- throw new Error('Please provide a vendor object.');
- }
-
- if (!isValidParticipantObject(options.buyer)) {
- throw new Error(getInvalidParticpantError('buyer'));
- }
-
- if (!isValidParticipantObject(options.vendor)) {
- throw new Error(getInvalidParticpantError('vendor'));
- }
-
- if (options.moderator && !isValidParticipantObject(options.moderator)) {
- throw new Error(getInvalidParticpantError('moderator'));
+ if (options.moderator) {
+ checkValidParticipantObject(options.moderator, 'moderator');
}
super(options);
diff --git a/js/views/modals/orderDetail/Discussion.js b/js/views/modals/orderDetail/Discussion.js
index f4fd5354b..2a4144e3a 100644
--- a/js/views/modals/orderDetail/Discussion.js
+++ b/js/views/modals/orderDetail/Discussion.js
@@ -7,6 +7,7 @@ import { getEmojiByName } from '../../../data/emojis';
import loadTemplate from '../../../utils/loadTemplate';
import ChatMessages from '../../../collections/ChatMessages';
import ChatMessage from '../../../models/chat/ChatMessage';
+import { checkValidParticipantObject } from './OrderDetail.js';
import baseVw from '../../baseVw';
import ConvoMessages from './ConvoMessages';
@@ -25,36 +26,11 @@ export default class extends baseVw {
'indicating whether Discussion is the active tab.');
}
- const isValidParticipantObject = (participant) => {
- let isValid = true;
- if (!participant.id) isValid = false;
- if (typeof participant.getProfile !== 'function') isValid = false;
- return isValid;
- };
-
- const getInvalidParticpantError = (type = '') =>
- (`The ${type} object is not valid. It should have an id ` +
- 'as well as a getProfile function that returns a promise that ' +
- 'resolves with a profile model.');
-
- if (!options.buyer) {
- throw new Error('Please provide a buyer object.');
- }
-
- if (!options.vendor) {
- throw new Error('Please provide a vendor object.');
- }
-
- if (!isValidParticipantObject(options.buyer)) {
- throw new Error(getInvalidParticpantError('buyer'));
- }
-
- if (!isValidParticipantObject(options.vendor)) {
- throw new Error(getInvalidParticpantError('vendor'));
- }
+ checkValidParticipantObject(options.buyer, 'buyer');
+ checkValidParticipantObject(options.vendor, 'vendor');
- if (options.moderator && !isValidParticipantObject(options.moderator)) {
- throw new Error(getInvalidParticpantError('moderator'));
+ if (options.moderator) {
+ checkValidParticipantObject(options.moderator, 'moderator');
}
super(options);
diff --git a/js/views/modals/orderDetail/DisputeOrder.js b/js/views/modals/orderDetail/DisputeOrder.js
new file mode 100644
index 000000000..4e8c3905c
--- /dev/null
+++ b/js/views/modals/orderDetail/DisputeOrder.js
@@ -0,0 +1,113 @@
+import {
+ openingDispute,
+ openDispute,
+ events as orderEvents,
+} from '../../../utils/order';
+import loadTemplate from '../../../utils/loadTemplate';
+import { checkValidParticipantObject } from './OrderDetail.js';
+import BaseVw from '../../baseVw';
+import ModFragment from './ModFragment';
+
+export default class extends BaseVw {
+ constructor(options = {}) {
+ super(options);
+
+ if (!this.model) {
+ throw new Error('Please provide an DisputeOrder model.');
+ }
+
+ checkValidParticipantObject(options.moderator, 'moderator');
+
+ options.moderator.getProfile()
+ .done((modProfile) => {
+ this.modProfile = modProfile;
+ if (this.moderatorVw) this.moderatorVw.setState({ ...modProfile.toJSON() });
+ });
+
+ this.options = options;
+
+ this.listenTo(orderEvents, 'openingDispute', this.onOpeningDispute);
+ this.listenTo(orderEvents, 'openDisputeComplete, openDisputeFail',
+ this.onOpenDisputeAlways);
+ }
+
+ className() {
+ return 'disputeOrderTab';
+ }
+
+ events() {
+ return {
+ 'click .js-backToSummary': 'onClickBackToSummary',
+ 'click .js-cancel': 'onClickCancel',
+ 'click .js-submit': 'onClickSubmit',
+ };
+ }
+
+ onClickBackToSummary() {
+ this.trigger('clickBackToSummary');
+ }
+
+ onClickCancel() {
+ const id = this.model.id;
+ this.model.reset();
+ // restore the id reset blew away
+ this.model.set({ orderId: id });
+ this.render();
+ this.trigger('clickCancel');
+ }
+
+ onClickSubmit() {
+ const formData = this.getFormData();
+ this.model.set(formData);
+ this.model.set({}, { validate: true });
+
+ if (!this.model.validationError) {
+ openDispute(this.model.id, this.model.toJSON());
+ }
+
+ this.render();
+ const $firstErr = this.$('.errorList:first');
+ if ($firstErr.length) $firstErr[0].scrollIntoViewIfNeeded();
+ }
+
+ onOpeningDisputeOrder(e) {
+ if (e.id === this.model.id) {
+ this.getCachedEl('.js-submit').addClass('processing');
+ this.getCachedEl('.js-cancel').addClass('disabled');
+ }
+ }
+
+ onOpenDisputeAlways(e) {
+ if (e.id === this.model.id) {
+ this.getCachedEl('.js-submit').removeClass('processing');
+ this.getCachedEl('.js-cancel').removeClass('disabled');
+ }
+ }
+
+ render() {
+ super.render();
+
+ loadTemplate('modals/orderDetail/disputeOrder.html', (t) => {
+ this.$el.html(t({
+ ...this.model.toJSON(),
+ errors: this.model.validationError || {},
+ openingDispute: !!openingDispute(this.model.id),
+ }));
+
+ const moderatorState = {
+ peerId: this.options.moderator.id,
+ showAvatar: true,
+ ...(this.modProfile && this.modProfile.toJSON() || {}),
+ };
+
+ if (this.moderatorVw) this.moderatorVw.remove();
+ this.moderatorVw = this.createChild(ModFragment, {
+ initialState: moderatorState,
+ });
+
+ this.$('.js-modContainer').html(this.moderatorVw.render().el);
+ });
+
+ return this;
+ }
+}
diff --git a/js/views/modals/orderDetail/FulfillOrder.js b/js/views/modals/orderDetail/FulfillOrder.js
index c3b215299..109b561e2 100644
--- a/js/views/modals/orderDetail/FulfillOrder.js
+++ b/js/views/modals/orderDetail/FulfillOrder.js
@@ -18,7 +18,13 @@ export default class extends BaseVw {
throw new Error('Please provide the contract type.');
}
+ if (typeof options.isLocalPickup !== 'boolean') {
+ throw new Error('Please provide a boolean indicating whether the item is to ' +
+ 'be picked up locally.');
+ }
+
this.contractType = options.contractType;
+ this.isLocalPickup = options.isLocalPickup;
this.listenTo(orderEvents, 'fulfillingOrder', this.onFulfillingOrder);
this.listenTo(orderEvents, 'fulfillOrderComplete, fulfillOrderFail',
this.onFulfillOrderAlways);
@@ -55,7 +61,7 @@ export default class extends BaseVw {
this.model.set({}, { validate: true });
if (!this.model.validationError) {
- fulfillOrder(this.model.id, this.model.toJSON());
+ fulfillOrder(this.contractType, this.isLocalPickup, this.model.toJSON());
}
this.render();
@@ -91,6 +97,7 @@ export default class extends BaseVw {
loadTemplate('modals/orderDetail/fulfillOrder.html', (t) => {
this.$el.html(t({
contractType: this.contractType,
+ isLocalPickup: this.isLocalPickup,
...this.model.toJSON(),
errors: this.model.validationError || {},
fulfillingOrder: fulfillingOrder(this.model.id),
diff --git a/js/views/modals/orderDetail/summaryTab/OrderDetailsModerator.js b/js/views/modals/orderDetail/ModFragment.js
similarity index 74%
rename from js/views/modals/orderDetail/summaryTab/OrderDetailsModerator.js
rename to js/views/modals/orderDetail/ModFragment.js
index 083399b57..277eca545 100644
--- a/js/views/modals/orderDetail/summaryTab/OrderDetailsModerator.js
+++ b/js/views/modals/orderDetail/ModFragment.js
@@ -1,15 +1,15 @@
import _ from 'underscore';
-import loadTemplate from '../../../../utils/loadTemplate';
-import BaseVw from '../../../baseVw';
+import loadTemplate from '../../../utils/loadTemplate';
+import BaseVw from '../../baseVw';
export default class extends BaseVw {
constructor(options = {}) {
super(options);
this.options = options;
- if (this.model) this.setModel(this.model);
-
this._state = {
+ maxPeerIdLength: 8,
+ showAvatar: false,
...options.initialState || {},
};
}
@@ -36,7 +36,7 @@ export default class extends BaseVw {
}
render() {
- loadTemplate('modals/orderDetail/summaryTab/orderDetailsModerator.html', t => {
+ loadTemplate('modals/orderDetail/modFragment.html', t => {
this.$el.html(t({
...this._state,
}));
diff --git a/js/views/modals/orderDetail/OrderDetail.js b/js/views/modals/orderDetail/OrderDetail.js
index 121d4efc4..c0eff0f57 100644
--- a/js/views/modals/orderDetail/OrderDetail.js
+++ b/js/views/modals/orderDetail/OrderDetail.js
@@ -3,16 +3,24 @@ import $ from 'jquery';
import app from '../../../app';
import { capitalize } from '../../../utils/string';
import { getSocket } from '../../../utils/serverConnect';
-import { events as orderEvents } from '../../../utils/order';
+import {
+ resolvingDispute,
+ events as orderEvents,
+} from '../../../utils/order';
import loadTemplate from '../../../utils/loadTemplate';
import Case from '../../../models/order/Case';
import OrderFulfillment from '../../../models/order/orderFulfillment/OrderFulfillment';
+import OrderDispute from '../../../models/order/OrderDispute';
+import ResolveDisputeMd from '../../../models/order/ResolveDispute';
import BaseModal from '../BaseModal';
import ProfileBox from './ProfileBox';
import Summary from './summaryTab/Summary';
import Discussion from './Discussion';
import Contract from './Contract';
import FulfillOrder from './FulfillOrder';
+import DisputeOrder from './DisputeOrder';
+import ResolveDispute from './ResolveDispute';
+import ActionBar from './ActionBar.js';
export default class extends BaseModal {
constructor(options = {}) {
@@ -47,10 +55,25 @@ export default class extends BaseModal {
this.listenToOnce(this.model, 'sync', this.onFirstOrderSync);
this.listenTo(this.model, 'change:unreadChatMessages',
() => this.setUnreadChatMessagesBadge());
+
this.listenTo(orderEvents, 'fulfillOrderComplete', () => {
if (this.activeTab === 'fulfillOrder') this.selectTab('summary');
});
+ this.listenTo(this.model, 'change:state', () => {
+ if (this.actionBar) {
+ this.actionBar.setState(this.actionBarButtonState);
+ }
+ });
+
+ this.listenTo(orderEvents, 'openDisputeComplete', () => {
+ if (this.activeTab === 'disputeOrder') this.selectTab('summary');
+ });
+
+ this.listenTo(orderEvents, 'resolveDisputeComplete', () => {
+ if (this.activeTab === 'resolveDispute') this.selectTab('summary');
+ });
+
const socket = getSocket();
if (socket) {
@@ -300,6 +323,8 @@ export default class extends BaseModal {
const view = this.createChild(Summary, viewData);
this.listenTo(view, 'clickFulfillOrder',
() => this.selectTab('fulfillOrder'));
+ this.listenTo(view, 'clickResolveDispute',
+ () => this.selectTab('resolveDispute'));
return view;
}
@@ -347,14 +372,68 @@ export default class extends BaseModal {
// This should not be called on a Case.
createFulfillOrderTabView() {
- const contractType = this.model.get('contract').type;
+ const contract = this.model.get('contract');
const model = new OrderFulfillment({ orderId: this.model.id },
- { contractType });
+ {
+ contractType: contract.type,
+ isLocalPickup: contract.isLocalPickup,
+ });
const view = this.createChild(FulfillOrder, {
+ model,
+ contractType: contract.type,
+ isLocalPickup: contract.isLocalPickup,
+ });
+
+ this.listenTo(view, 'clickBackToSummary clickCancel', () => this.selectTab('summary'));
+
+ return view;
+ }
+
+ createDisputeOrderTabView() {
+ const contractType = this.model.get('contract').type;
+
+ const model = new OrderDispute({ orderId: this.model.id });
+
+ const view = this.createChild(DisputeOrder, {
model,
contractType,
+ moderator: {
+ id: this.moderatorId,
+ getProfile: this.getModeratorProfile.bind(this),
+ },
+ });
+
+ this.listenTo(view, 'clickBackToSummary clickCancel', () => this.selectTab('summary'));
+
+ return view;
+ }
+
+ createResolveDisputeTabView() {
+ let modelAttrs = { orderId: this.model.id };
+ const isResolvingDispute = resolvingDispute(this.model.id);
+
+ // If this order is in the process of the dispute being resolved, we'll
+ // populate the model with the data that was posted to the server.
+ if (isResolvingDispute) {
+ modelAttrs = {
+ ...modelAttrs,
+ ...isResolvingDispute.data,
+ };
+ }
+
+ const model = new ResolveDisputeMd(modelAttrs);
+ const view = this.createChild(ResolveDispute, {
+ model,
+ vendor: {
+ id: this.vendorId,
+ getProfile: this.getVendorProfile.bind(this),
+ },
+ buyer: {
+ id: this.buyerId,
+ getProfile: this.getBuyerProfile.bind(this),
+ },
});
this.listenTo(view, 'clickBackToSummary clickCancel', () => this.selectTab('summary'));
@@ -373,6 +452,27 @@ export default class extends BaseModal {
return count;
}
+ /**
+ * Returns whether different action bar buttons should be displayed or not
+ * based upon the order state.
+ */
+ get actionBarButtonState() {
+ const orderState = this.model.get('state');
+ let showDisputeOrderButton = false;
+
+ if (this.buyerId === app.profile.id) {
+ showDisputeOrderButton = this.moderatorId &&
+ ['AWAITING_FULFILLMENT', 'PENDING', 'FULFILLED'].indexOf(orderState) > -1;
+ } else if (this.vendorId === app.profile.id) {
+ showDisputeOrderButton = this.moderatorId &&
+ ['AWAITING_FULFILLMENT', 'FULFILLED'].indexOf(orderState) > -1;
+ }
+
+ return {
+ showDisputeOrderButton,
+ };
+ }
+
get $unreadChatMessagesBadge() {
return this._$unreadChatMessagesBadge ||
(this._$unreadChatMessagesBadge = this.$('.js-unreadChatMessagesBadge'));
@@ -406,9 +506,33 @@ export default class extends BaseModal {
if (!state.isFetching && !state.fetchError) {
this.selectTab(this.activeTab);
+
+ if (this.actionBar) this.actionBar.remove();
+ this.actionBar = this.createChild(ActionBar, {
+ orderId: this.model.id,
+ initialState: this.actionBarButtonState,
+ });
+ this.$('.js-actionBarContainer').html(this.actionBar.render().el);
+ this.listenTo(this.actionBar, 'clickOpenDispute', () => this.selectTab('disputeOrder'));
}
});
return this;
}
}
+
+export function checkValidParticipantObject(participant, type) {
+ if (typeof participant !== 'object') {
+ throw new Error(`Please provide a participant object for the ${type}.`);
+ }
+
+ if (typeof type !== 'string') {
+ throw new Error('Please provide the participant type as a string.');
+ }
+
+ if (!participant.id || typeof participant.getProfile !== 'function') {
+ throw new Error(`The ${type} object is not valid. It should have an id ` +
+ 'as well as a getProfile function that returns a promise that ' +
+ 'resolves with a profile model.');
+ }
+}
diff --git a/js/views/modals/orderDetail/ResolveDispute.js b/js/views/modals/orderDetail/ResolveDispute.js
new file mode 100644
index 000000000..b46837355
--- /dev/null
+++ b/js/views/modals/orderDetail/ResolveDispute.js
@@ -0,0 +1,154 @@
+import $ from 'jquery';
+import { getAvatarBgImage } from '../../../utils/responsive';
+import {
+ resolvingDispute,
+ resolveDispute,
+ events as orderEvents,
+} from '../../../utils/order';
+import { checkValidParticipantObject } from './OrderDetail.js';
+import loadTemplate from '../../../utils/loadTemplate';
+import BaseVw from '../../baseVw';
+
+export default class extends BaseVw {
+ constructor(options = {}) {
+ super(options);
+
+ if (!this.model) {
+ throw new Error('Please provide an OrderFulfillment model.');
+ }
+
+ checkValidParticipantObject(options.buyer, 'buyer');
+ checkValidParticipantObject(options.vendor, 'vendor');
+
+ options.buyer.getProfile().done(profile => {
+ this.buyerProfile = profile;
+ if (this.rendered) {
+ this.getCachedEl('.js-buyerName').text(this.buyerProfile.get('name'));
+ this.getCachedEl('.js-buyerAvatar')
+ .attr('style', getAvatarBgImage(profile.get('avatarHashes').toJSON()));
+ }
+ });
+
+ options.vendor.getProfile().done(profile => {
+ this.vendorProfile = profile;
+ if (this.rendered) {
+ this.getCachedEl('.js-vendorName').text(this.vendorProfile.get('name'));
+ this.getCachedEl('.js-vendorAvatar')
+ .attr('style', getAvatarBgImage(profile.get('avatarHashes').toJSON()));
+ }
+ });
+
+ this.listenTo(orderEvents, 'resolvingDispute', this.onResolvingDispute);
+ this.listenTo(orderEvents, 'resolveDisputeComplete resolveDisputeFail',
+ this.onResolveDisputeAlways);
+
+ this.boundOnDocClick = this.onDocumentClick.bind(this);
+ $(document).on('click', this.boundOnDocClick);
+ }
+
+ className() {
+ return 'resolveDisputeTab';
+ }
+
+ events() {
+ return {
+ 'click .js-backToSummary': 'onClickBackToSummary',
+ 'click .js-cancel': 'onClickCancel',
+ 'click .js-submit': 'onClickSubmit',
+ 'click .js-resolveConfirmed': 'onClickConfirmedSubmit',
+ 'click .js-resolveConfirm': 'onClickResolveConfirmBox',
+ 'click .js-resolveConfirmCancel': 'onClickCancelConfirm',
+ };
+ }
+
+ onClickResolveConfirmBox() {
+ // ensure event doesn't bubble so onDocumentClick doesn't
+ // close the confirmBox.
+ return false;
+ }
+
+ onClickCancelConfirm() {
+ this.getCachedEl('.js-resolveConfirm').addClass('hide');
+ }
+
+ onDocumentClick() {
+ this.getCachedEl('.js-resolveConfirm').addClass('hide');
+ }
+
+ onClickBackToSummary() {
+ this.trigger('clickBackToSummary');
+ }
+
+ onClickCancel() {
+ const id = this.model.id;
+ this.model.reset();
+ // restore the id reset blew away
+ this.model.set({ orderId: id });
+ this.render();
+ this.trigger('clickCancel');
+ }
+
+ onClickSubmit() {
+ this.getCachedEl('.js-resolveConfirm').removeClass('hide');
+ return false;
+ }
+
+ onClickConfirmedSubmit() {
+ const formData = this.getFormData();
+ this.model.set(formData);
+ this.model.set({}, { validate: true });
+
+ if (!this.model.validationError) {
+ resolveDispute(this.model.id, this.model.toJSON());
+ }
+
+ this.render();
+ const $firstErr = this.$('.errorList:first');
+ if ($firstErr.length) $firstErr[0].scrollIntoViewIfNeeded();
+ }
+
+ onResolvingDispute(e) {
+ if (e.id === this.model.id) {
+ this.getCachedEl('.js-submit').addClass('processing');
+ this.getCachedEl('.js-cancel').addClass('disabled');
+ }
+ }
+
+ onResolveDisputeAlways(e) {
+ if (e.id === this.model.id) {
+ this.getCachedEl('.js-submit').removeClass('processing');
+ this.getCachedEl('.js-cancel').removeClass('disabled');
+ }
+ }
+
+ remove() {
+ $(document).off(null, this.boundOnDocClick);
+ super.remove();
+ }
+
+ render() {
+ super.render();
+ loadTemplate('modals/orderDetail/resolveDispute.html', (t) => {
+ const templateData = {
+ ...this.model.toJSON(),
+ errors: this.model.validationError || {},
+ resolvingDispute: resolvingDispute(this.model.id),
+ };
+
+ if (this.buyerProfile) {
+ templateData.buyerAvatarHashes = this.buyerProfile.get('avatarHashes').toJSON();
+ templateData.buyerName = this.buyerProfile.get('name');
+ }
+
+ if (this.vendorProfile) {
+ templateData.vendorAvatarHashes = this.vendorProfile.get('avatarHashes').toJSON();
+ templateData.vendorName = this.vendorProfile.get('name');
+ }
+
+ this.$el.html(t(templateData));
+ this.rendered = true;
+ });
+
+ return this;
+ }
+}
diff --git a/js/views/modals/orderDetail/summaryTab/Accepted.js b/js/views/modals/orderDetail/summaryTab/Accepted.js
index 334fcde36..6ed50a7fc 100644
--- a/js/views/modals/orderDetail/summaryTab/Accepted.js
+++ b/js/views/modals/orderDetail/summaryTab/Accepted.js
@@ -119,6 +119,11 @@ export default class extends BaseVw {
return this;
}
+ remove() {
+ $(document).off('click', this.boundOnDocClick);
+ super.remove();
+ }
+
render() {
loadTemplate('modals/orderDetail/summaryTab/accepted.html', (t) => {
this.$el.html(t({
diff --git a/js/views/modals/orderDetail/summaryTab/CompleteOrderForm.js b/js/views/modals/orderDetail/summaryTab/CompleteOrderForm.js
index 8124b6c9a..740d879ae 100644
--- a/js/views/modals/orderDetail/summaryTab/CompleteOrderForm.js
+++ b/js/views/modals/orderDetail/summaryTab/CompleteOrderForm.js
@@ -53,8 +53,11 @@ export default class extends BaseVw {
}
onClickCompleteOrder() {
+ const formData = this.getFormData();
+
const data = {
- ...this.getFormData(),
+ ...formData,
+ anonymous: !formData.anonymous,
// If a rating is not set, the RatingStrip view will return 0. We'll
// send undefined in that case since it gives us the error message we
// prefer.
diff --git a/js/views/modals/orderDetail/summaryTab/DisputeAcceptance.js b/js/views/modals/orderDetail/summaryTab/DisputeAcceptance.js
new file mode 100644
index 000000000..36a0cc7fc
--- /dev/null
+++ b/js/views/modals/orderDetail/summaryTab/DisputeAcceptance.js
@@ -0,0 +1,53 @@
+import _ from 'underscore';
+import moment from 'moment';
+import loadTemplate from '../../../../utils/loadTemplate';
+import BaseVw from '../../../baseVw';
+
+export default class extends BaseVw {
+ constructor(options = {}) {
+ super(options);
+
+ this._state = {
+ closerName: '',
+ closerAvatarHashes: {},
+ buyerViewing: false,
+ ...options.initialState || {},
+ };
+ }
+
+ className() {
+ return 'disputeAcceptanceEvent rowLg';
+ }
+
+ getState() {
+ return this._state;
+ }
+
+ setState(state, replace = false, renderOnChange = true) {
+ let newState;
+
+ if (replace) {
+ this._state = {};
+ } else {
+ newState = _.extend({}, this._state, state);
+ }
+
+ if (renderOnChange && !_.isEqual(this._state, newState)) {
+ this._state = newState;
+ this.render();
+ }
+
+ return this;
+ }
+
+ render() {
+ loadTemplate('modals/orderDetail/summaryTab/disputeAcceptance.html', (t) => {
+ this.$el.html(t({
+ ...this._state,
+ moment,
+ }));
+ });
+
+ return this;
+ }
+}
diff --git a/js/views/modals/orderDetail/summaryTab/DisputePayout.js b/js/views/modals/orderDetail/summaryTab/DisputePayout.js
new file mode 100644
index 000000000..4e6b8fc61
--- /dev/null
+++ b/js/views/modals/orderDetail/summaryTab/DisputePayout.js
@@ -0,0 +1,126 @@
+import $ from 'jquery';
+import app from '../../../../app';
+import _ from 'underscore';
+import moment from 'moment';
+import {
+ acceptingPayout,
+ acceptPayout,
+ events as orderEvents,
+} from '../../../../utils/order';
+import loadTemplate from '../../../../utils/loadTemplate';
+import BaseVw from '../../../baseVw';
+
+export default class extends BaseVw {
+ constructor(options = {}) {
+ super(options);
+
+ if (!options.orderId) {
+ throw new Error('Please provide the orderId');
+ }
+
+ this.orderId = options.orderId;
+
+ this._state = {
+ userCurrency: app.settings.get('localCurrency') || 'USD',
+ showAcceptButton: false,
+ acceptConfirmOn: false,
+ ...options.initialState || {},
+ };
+
+ this.boundOnDocClick = this.onDocumentClick.bind(this);
+ $(document).on('click', this.boundOnDocClick);
+
+ this.listenTo(orderEvents, 'acceptingPayout', e => {
+ if (e.id === this.orderId) {
+ this.setState({ acceptInProgress: true });
+ }
+ });
+
+ this.listenTo(orderEvents, 'acceptPayoutComplete acceptPayoutFail', e => {
+ if (e.id === this.orderId) {
+ this.setState({ acceptInProgress: false });
+ }
+ });
+
+ this.listenTo(orderEvents, 'acceptPayoutComplete', e => {
+ if (e.id === this.orderId) {
+ this.setState({ showAcceptButton: false });
+ }
+ });
+ }
+
+ className() {
+ return 'disputePayoutEvent rowLg';
+ }
+
+ events() {
+ return {
+ 'click .js-acceptPayout': 'onClickAcceptPayout',
+ 'click .js-acceptPayoutConfirmBox': 'onClickAcceptPayoutConfirmedBox',
+ 'click .js-acceptPayoutConfirmed': 'onClickAcceptPayoutConfirmed',
+ 'click .js-acceptPayoutConfirmCancel': 'onClickAcceptPayoutConfirmCancel',
+ };
+ }
+
+ onDocumentClick() {
+ this.setState({ acceptConfirmOn: false });
+ }
+
+ onClickAcceptPayout() {
+ this.setState({ acceptConfirmOn: true });
+ return false;
+ }
+
+ onClickAcceptPayoutConfirmedBox() {
+ // ensure event doesn't bubble so onDocumentClick doesn't
+ // close the confirmBox.
+ return false;
+ }
+
+ onClickAcceptPayoutConfirmCancel() {
+ this.setState({ acceptConfirmOn: false });
+ }
+
+ onClickAcceptPayoutConfirmed() {
+ this.setState({ acceptConfirmOn: false });
+ acceptPayout(this.orderId);
+ }
+
+ getState() {
+ return this._state;
+ }
+
+ setState(state, replace = false, renderOnChange = true) {
+ let newState;
+
+ if (replace) {
+ this._state = {};
+ } else {
+ newState = _.extend({}, this._state, state);
+ }
+
+ if (renderOnChange && !_.isEqual(this._state, newState)) {
+ this._state = newState;
+ this.render();
+ }
+
+ return this;
+ }
+
+ remove() {
+ $(document).off('click', this.boundOnDocClick);
+ super.remove();
+ }
+
+ render() {
+ loadTemplate('modals/orderDetail/summaryTab/disputePayout.html', (t) => {
+ this.$el.html(t({
+ ...this._state,
+ moment,
+ acceptInProgress: acceptingPayout(this.orderId),
+ }));
+ });
+
+ return this;
+ }
+}
diff --git a/js/views/modals/orderDetail/summaryTab/DisputeStarted.js b/js/views/modals/orderDetail/summaryTab/DisputeStarted.js
new file mode 100644
index 000000000..89838b5c4
--- /dev/null
+++ b/js/views/modals/orderDetail/summaryTab/DisputeStarted.js
@@ -0,0 +1,72 @@
+import _ from 'underscore';
+import moment from 'moment';
+import {
+ events as orderEvents,
+} from '../../../../utils/order';
+import loadTemplate from '../../../../utils/loadTemplate';
+import BaseVw from '../../../baseVw';
+
+export default class extends BaseVw {
+ constructor(options = {}) {
+ super(options);
+
+ this._state = {
+ disputerName: '',
+ claim: '',
+ showResolveButton: true,
+ ...options.initialState || {},
+ };
+
+ this.listenTo(orderEvents, 'resolveDisputeComplete', () => {
+ this.setState({
+ showResolveButton: false,
+ });
+ });
+ }
+
+ className() {
+ return 'disputeStartedEvent rowLg';
+ }
+
+ events() {
+ return {
+ 'click .js-resolveDispute': 'onClickResolveDispute',
+ };
+ }
+
+ onClickResolveDispute() {
+ this.trigger('clickResolveDispute');
+ }
+
+ getState() {
+ return this._state;
+ }
+
+ setState(state, replace = false, renderOnChange = true) {
+ let newState;
+
+ if (replace) {
+ this._state = {};
+ } else {
+ newState = _.extend({}, this._state, state);
+ }
+
+ if (renderOnChange && !_.isEqual(this._state, newState)) {
+ this._state = newState;
+ this.render();
+ }
+
+ return this;
+ }
+
+ render() {
+ loadTemplate('modals/orderDetail/summaryTab/disputeStarted.html', (t) => {
+ this.$el.html(t({
+ ...this._state,
+ moment,
+ }));
+ });
+
+ return this;
+ }
+}
diff --git a/js/views/modals/orderDetail/summaryTab/Fulfilled.js b/js/views/modals/orderDetail/summaryTab/Fulfilled.js
index ceb954ead..468c422bd 100644
--- a/js/views/modals/orderDetail/summaryTab/Fulfilled.js
+++ b/js/views/modals/orderDetail/summaryTab/Fulfilled.js
@@ -15,6 +15,7 @@ export default class extends BaseVw {
this._state = {
contractType: 'PHYSICAL_GOOD',
+ isLocalPickup: false,
showPassword: false,
...options.initialState || {},
};
diff --git a/js/views/modals/orderDetail/summaryTab/OrderDetails.js b/js/views/modals/orderDetail/summaryTab/OrderDetails.js
index a5b530e4c..4483df4f5 100644
--- a/js/views/modals/orderDetail/summaryTab/OrderDetails.js
+++ b/js/views/modals/orderDetail/summaryTab/OrderDetails.js
@@ -6,7 +6,8 @@ import { convertAndFormatCurrency } from '../../../../utils/currency';
import { clipboard } from 'electron';
import '../../../../utils/velocity';
import loadTemplate from '../../../../utils/loadTemplate';
-import Moderator from './OrderDetailsModerator';
+import ModFragment from '../ModFragment';
+import { checkValidParticipantObject } from '../OrderDetail.js';
import BaseVw from '../../../baseVw';
export default class extends BaseVw {
@@ -17,26 +18,14 @@ export default class extends BaseVw {
throw new Error('Please provide a Contract model.');
}
- const isValidParticipantObject = (participant) => {
- let isValid = true;
- if (!participant.id) isValid = false;
- if (typeof participant.getProfile !== 'function') isValid = false;
- return isValid;
- };
-
- const getInvalidParticpantError = (type = '') =>
- (`The ${type} object is not valid. It should have an id ` +
- 'as well as a getProfile function that returns a promise that ' +
- 'resolves with a profile model.');
-
- if (this.model.get('buyerOrder').payment.moderator) {
- if (!options.moderator) {
- throw new Error('Please provide a moderator object.');
- }
+ if (this.isModerated()) {
+ checkValidParticipantObject(options.moderator, 'moderator');
- if (!isValidParticipantObject(options.moderator)) {
- throw new Error(getInvalidParticpantError('moderator'));
- }
+ options.moderator.getProfile()
+ .done((modProfile) => {
+ this.modProfile = modProfile;
+ if (this.moderatorVw) this.moderatorVw.setState({ ...modProfile.toJSON() });
+ });
}
this.options = options;
@@ -66,6 +55,10 @@ export default class extends BaseVw {
});
}
+ isModerated() {
+ return !!this.model.get('buyerOrder').payment.moderator;
+ }
+
get $copiedToClipboard() {
return this._$copiedToClipboard ||
(this._$copiedToClipboard = this.$('.js-orderDetailsCopiedToClipboard'));
@@ -80,34 +73,24 @@ export default class extends BaseVw {
convertAndFormatCurrency,
userCurrency: app.settings.get('localCurrency'),
moment,
- moderator: this.modProfile && this.modProfile.toJSON() || null,
+ isModerated: this.isModerated(),
}));
this._$copiedToClipboard = null;
- const moderatorState = {};
+ if (this.isModerated()) {
+ const moderatorState = {
+ peerId: this.options.moderator.id,
+ ...(this.modProfile && this.modProfile.toJSON() || {}),
+ };
- if (this.options.moderator) {
- moderatorState.peerId = this.options.moderator.id;
- }
+ if (this.moderatorVw) this.moderatorVw.remove();
+ this.moderatorVw = this.createChild(ModFragment, {
+ initialState: moderatorState,
+ });
- if (this.moderatorVw) this.moderatorVw.remove();
- this.moderatorVw = this.createChild(Moderator, {
- initialState: moderatorState,
- });
-
- if (!this.modProfile && this.options.moderator) {
- this.options.moderator.getProfile()
- .done((modProfile) => {
- this.modProfile = modProfile;
- this.moderatorVw.setState({
- name: modProfile.get('name'),
- handle: modProfile.get('handle'),
- });
- });
+ this.$('.js-moderatorContainer').html(this.moderatorVw.render().el);
}
-
- this.$('.js-moderatorContainer').html(this.moderatorVw.render().el);
});
return this;
diff --git a/js/views/modals/orderDetail/summaryTab/Payment.js b/js/views/modals/orderDetail/summaryTab/Payment.js
index 8874de0d2..c72b0e3c6 100644
--- a/js/views/modals/orderDetail/summaryTab/Payment.js
+++ b/js/views/modals/orderDetail/summaryTab/Payment.js
@@ -101,6 +101,11 @@ export default class extends BaseVw {
return this;
}
+ remove() {
+ $(document).off('click', this.boundOnDocClick);
+ super.remove();
+ }
+
render() {
loadTemplate('modals/orderDetail/summaryTab/payment.html', (t) => {
this.$el.html(t({
diff --git a/js/views/modals/orderDetail/summaryTab/Payments.js b/js/views/modals/orderDetail/summaryTab/Payments.js
index d202d3f99..cc4321727 100644
--- a/js/views/modals/orderDetail/summaryTab/Payments.js
+++ b/js/views/modals/orderDetail/summaryTab/Payments.js
@@ -8,6 +8,7 @@ import {
cancelOrder,
events as orderEvents,
} from '../../../../utils/order';
+import { checkValidParticipantObject } from '../OrderDetail.js';
import baseVw from '../../../baseVw';
import Payment from './Payment';
@@ -39,25 +40,7 @@ export default class extends baseVw {
'confirmed by the current user.');
}
- const isValidParticipantObject = (participant) => {
- let isValid = true;
- if (!participant.id) isValid = false;
- if (typeof participant.getProfile !== 'function') isValid = false;
- return isValid;
- };
-
- const getInvalidParticpantError = (type = '') =>
- (`The ${type} object is not valid. It should have an id ` +
- 'as well as a getProfile function that returns a promise that ' +
- 'resolves with a profile model.');
-
- if (!opts.vendor) {
- throw new Error('Please provide a vendor object.');
- }
-
- if (!isValidParticipantObject(options.vendor)) {
- throw new Error(getInvalidParticpantError('vendor'));
- }
+ checkValidParticipantObject(options.vendor, 'vendor');
super(opts);
this.options = opts;
@@ -177,9 +160,11 @@ export default class extends baseVw {
this.payments = [];
this.collection.models.forEach((payment, index) => {
- const paidSoFar = this.collection.models
+ let paidSoFar = this.collection.models
.slice(0, index + 1)
.reduce((total, model) => total + model.get('value'), 0);
+ // round to 8 decimal places
+ paidSoFar = Math.round(paidSoFar * 100000000) / 100000000;
const isMostRecentPayment = index === this.collection.length - 1;
const paymentView = this.createPayment(payment, {
initialState: {
diff --git a/js/views/modals/orderDetail/summaryTab/Summary.js b/js/views/modals/orderDetail/summaryTab/Summary.js
index c8c5da70f..65205870d 100644
--- a/js/views/modals/orderDetail/summaryTab/Summary.js
+++ b/js/views/modals/orderDetail/summaryTab/Summary.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import app from '../../../../app';
import { clipboard } from 'electron';
import '../../../../utils/velocity';
@@ -10,6 +9,7 @@ import {
} from '../../../../utils/order';
import Transactions from '../../../../collections/Transactions';
import OrderCompletion from '../../../../models/order/orderCompletion/OrderCompletion';
+import { checkValidParticipantObject } from '../OrderDetail.js';
import BaseVw from '../../../baseVw';
import StateProgressBar from './StateProgressBar';
import Payments from './Payments';
@@ -19,14 +19,14 @@ import Refunded from './Refunded';
import OrderDetails from './OrderDetails';
import CompleteOrderForm from './CompleteOrderForm';
import OrderComplete from './OrderComplete';
+import DisputeStarted from './DisputeStarted';
+import DisputePayout from './DisputePayout';
+import DisputeAcceptance from './DisputeAcceptance';
+import PayForOrder from '../../../modals/purchase/Payment';
export default class extends BaseVw {
constructor(options = {}) {
- const opts = {
- ...options,
- };
-
- super(opts);
+ super(options);
if (!this.model) {
throw new Error('Please provide a model.');
@@ -44,48 +44,17 @@ export default class extends BaseVw {
this.contract = contract;
- const isValidParticipantObject = (participant) => {
- let isValid = true;
- if (!participant.id) isValid = false;
- if (typeof participant.getProfile !== 'function') isValid = false;
- return isValid;
- };
-
- const getInvalidParticpantError = (type = '') =>
- (`The ${type} object is not valid. It should have an id ` +
- 'as well as a getProfile function that returns a promise that ' +
- 'resolves with a profile model.');
-
- if (!opts.vendor) {
- throw new Error('Please provide a vendor object.');
- }
-
- if (!isValidParticipantObject(options.vendor)) {
- throw new Error(getInvalidParticpantError('vendor'));
- }
-
- if (!opts.buyer) {
- throw new Error('Please provide a buyer object.');
- }
-
- if (!isValidParticipantObject(options.buyer)) {
- throw new Error(getInvalidParticpantError('buyer'));
- }
+ checkValidParticipantObject(options.buyer, 'buyer');
+ checkValidParticipantObject(options.vendor, 'vendor');
if (this.contract.get('buyerOrder').payment.moderator) {
- if (!options.moderator) {
- throw new Error('Please provide a moderator object.');
- }
-
- if (!isValidParticipantObject(options.moderator)) {
- throw new Error(getInvalidParticpantError('moderator'));
- }
+ checkValidParticipantObject(options.moderator, 'moderator');
}
- this.options = opts || {};
- this.vendor = opts.vendor;
- this.buyer = opts.buyer;
- this.moderator = opts.moderator;
+ this.options = options || {};
+ this.vendor = options.vendor;
+ this.buyer = options.buyer;
+ this.moderator = options.moderator;
this.listenTo(this.model, 'change:state', (md, state) => {
this.stateProgressBar.setState(this.progressBarState);
@@ -96,23 +65,33 @@ export default class extends BaseVw {
if (this.accepted) this.accepted.remove();
}
- if (state === 'REFUNDED' || state === 'FULFILLED' && this.accepted) {
- this.accepted.setState({
- showRefundButton: false,
+ if (
+ ['REFUNDED', 'FULFILLED', 'DISPUTED', 'DECIDED', 'RESOLVED', 'COMPLETE'].indexOf(state) > -1
+ && this.accepted) {
+ const acceptedState = {
showFulfillButton: false,
infoText: app.polyglot.t('orderDetail.summaryTab.accepted.vendorReceived'),
- });
+ };
+
+ if (state !== 'DISPUTED') {
+ acceptedState.showRefundButton = false;
+ }
+
+ this.accepted.setState(acceptedState);
}
- if (state === 'COMPLETED' && this.completeOrderForm) {
+ if (this.completeOrderForm &&
+ ['FULFILLED', 'RESOLVED'].indexOf(state) === -1) {
this.completeOrderForm.remove();
+ this.completeOrderForm = null;
}
});
if (!this.isCase()) {
this.listenTo(this.model.get('paymentAddressTransactions'), 'update', () => {
- if (!this.shouldShowPayForOrderSection()) {
- this.$('.js-payForOrderWrap').remove();
+ if (this.payForOrder && !this.shouldShowPayForOrderSection()) {
+ this.payForOrder.remove();
+ this.payForOrder = null;
}
if (this.payments) {
@@ -186,6 +165,49 @@ export default class extends BaseVw {
}
});
+ this.listenTo(orderEvents, 'openDisputeComplete', e => {
+ if (e.id === this.model.id) {
+ this.model.set('state', 'DISPUTED');
+ this.model.fetch();
+ }
+ });
+
+ if (!this.isCase()) {
+ this.listenTo(this.contract, 'change:dispute',
+ () => this.renderDisputeStartedView());
+
+ this.listenTo(this.contract, 'change:disputeResolution', () => {
+ // Only render the dispute payout the first time we receive it
+ // (it changes from undefined to an object with data). It shouldn't
+ // be changing after that, but for some reason it is.
+ if (!this.contract.previous('disputeResolution')) {
+ // The timeout is needed in the handler so the updated
+ // order state is available.
+ setTimeout(() => this.renderDisputePayoutView());
+ }
+ });
+
+ this.listenTo(orderEvents, 'acceptPayoutComplete', e => {
+ if (e.id === this.model.id) {
+ this.model.set('state', 'RESOLVED');
+ this.model.fetch();
+ }
+ });
+
+ this.listenTo(this.contract, 'change:disputeAcceptance',
+ () => this.renderDisputeAcceptanceView());
+ } else {
+ this.listenTo(orderEvents, 'resolveDisputeComplete', e => {
+ if (e.id === this.model.id) {
+ this.model.set('state', 'RESOLVED');
+ this.model.fetch();
+ }
+ });
+
+ this.listenTo(this.model, 'change:resolution',
+ () => this.renderDisputePayoutView());
+ }
+
const serverSocket = getSocket();
if (serverSocket) {
@@ -222,6 +244,16 @@ export default class extends BaseVw {
e.jsonData.notification.orderCompletion.orderId === this.model.id) {
// A notification the vendor will get when the buyer has completed an order.
this.model.fetch();
+ } else if (e.jsonData.notification.disputeOpen &&
+ e.jsonData.notification.disputeOpen.orderId === this.model.id) {
+ // When a party opens a dispute the mod and the other party will get this
+ // notification
+ this.model.fetch();
+ } else if (e.jsonData.notification.disputeClose &&
+ e.jsonData.notification.disputeClose.orderId === this.model.id) {
+ // Notification to the vendor and buyer when a mod has made a decision
+ // on an open dispute.
+ this.model.fetch();
}
}
});
@@ -271,10 +303,9 @@ export default class extends BaseVw {
],
};
- // TODO: add in completed check with determination of whether a dispute
- // had been opened.
if (orderState === 'DISPUTED' || orderState === 'DECIDED' ||
- orderState === 'RESOLVED') {
+ orderState === 'RESOLVED' ||
+ (orderState === 'COMPLETED' && this.model.get('dispute') !== undefined)) {
if (!this.isCase()) {
state.states = [
app.polyglot.t('orderDetail.summaryTab.orderDetails.progressBarStates.disputed'),
@@ -285,15 +316,15 @@ export default class extends BaseVw {
switch (orderState) {
case 'DECIDED':
- state.currentState = 1;
+ state.currentState = 2;
state.disputeState = 0;
break;
case 'RESOLVED':
- state.currentState = 2;
+ state.currentState = 3;
state.disputeState = 0;
break;
case 'COMPLETE':
- state.currentState = 3;
+ state.currentState = 4;
state.disputeState = 0;
break;
default:
@@ -322,6 +353,7 @@ export default class extends BaseVw {
`orderDetail.summaryTab.orderDetails.progressBarStates.${orderState.toLowerCase()}`),
];
state.currentState = 2;
+ state.disputeState = 0;
} else {
switch (orderState) {
case 'PENDING':
@@ -363,7 +395,8 @@ export default class extends BaseVw {
balanceRemaining = this.orderPriceBtc - totalPaid;
}
- return balanceRemaining;
+ // round to 8 decimal places
+ return Math.round(balanceRemaining * 100000000) / 100000000;
}
shouldShowPayForOrderSection() {
@@ -374,7 +407,8 @@ export default class extends BaseVw {
let bool = false;
// Show the accepted section if the order has been accepted and its fully funded.
- if (this.contract.get('vendorOrderConfirmation') && this.getBalanceRemaining() <= 0) {
+ if (this.contract.get('vendorOrderConfirmation')
+ && (this.isCase() || this.getBalanceRemaining() <= 0)) {
bool = true;
}
@@ -481,6 +515,19 @@ export default class extends BaseVw {
this.$subSections.prepend(this.refunded.render().el);
}
+ renderCompleteOrderForm() {
+ const completingObject = completingOrder(this.model.id);
+ const model = new OrderCompletion(
+ completingObject ? completingObject.data : { orderId: this.model.id });
+ if (this.completeOrderForm) this.completeOrderForm.remove();
+ this.completeOrderForm = this.createChild(CompleteOrderForm, {
+ model,
+ slug: this.contract.get('vendorListings').at(0).get('slug'),
+ });
+
+ this.$subSections.prepend(this.completeOrderForm.render().el);
+ }
+
renderFulfilledView() {
const data = this.contract.get('vendorOrderFulfillment');
@@ -495,6 +542,7 @@ export default class extends BaseVw {
initialState: {
contractType: this.contract.type,
showPassword: this.moderator && this.moderator.id !== app.profile.id || true,
+ isLocalPickup: this.contract.isLocalPickup,
},
});
@@ -502,26 +550,11 @@ export default class extends BaseVw {
.done(profile =>
this.fulfilled.setState({ storeName: profile.get('name') }));
- const sections = document.createDocumentFragment();
- const $sections = $(sections).append(this.fulfilled.render().el);
-
- // If the order is not complete and this is the buyer, we'll
- // render a complete order form.
- if (['FULFILLED', 'RESOLVED'].indexOf(this.model.get('state')) > -1 &&
- this.buyer.id === app.profile.id) {
- const completingObject = completingOrder(this.model.id);
- const model = completingObject ?
- completingObject.model : new OrderCompletion({ orderId: this.model.id });
- if (this.completeOrderForm) this.completeOrderForm.remove();
- this.completeOrderForm = this.createChild(CompleteOrderForm, {
- model,
- slug: this.contract.get('vendorListings').at(0).get('slug'),
- });
+ this.$subSections.prepend(this.fulfilled.render().el);
- $sections.prepend(this.completeOrderForm.render().el);
+ if (this.model.get('state') === 'FULFILLED' && this.buyer.id === app.profile.id) {
+ this.renderCompleteOrderForm();
}
-
- this.$subSections.prepend($sections);
}
renderOrderCompleteView() {
@@ -543,6 +576,121 @@ export default class extends BaseVw {
this.$subSections.prepend(this.orderComplete.render().el);
}
+ renderDisputeStartedView() {
+ const data = this.isCase() ? {
+ timestamp: this.model.get('timestamp'),
+ claim: this.model.get('claim'),
+ } : this.contract.get('dispute');
+
+ if (!data) {
+ throw new Error('Unable to create the Dispute Started view because the dispute ' +
+ 'data object has not been set.');
+ }
+
+ if (this.disputeStarted) this.disputeStarted.remove();
+ this.disputeStarted = this.createChild(DisputeStarted, {
+ initialState: {
+ ...data,
+ showResolveButton: this.model.get('state') === 'DISPUTED' &&
+ this.moderator.id === app.profile.id,
+ },
+ });
+
+ // this is only set on the Case.
+ const buyerOpened = this.model.get('buyerOpened');
+ if (typeof buyerOpened !== 'undefined') {
+ const disputeOpener = buyerOpened ? this.buyer : this.vendor;
+ disputeOpener.getProfile()
+ .done(profile =>
+ this.disputeStarted.setState({ disputerName: profile.get('name') }));
+ }
+
+ this.listenTo(this.disputeStarted, 'clickResolveDispute',
+ () => this.trigger('clickResolveDispute'));
+
+ this.$subSections.prepend(this.disputeStarted.render().el);
+ }
+
+ renderDisputePayoutView() {
+ const data = this.isCase() ? this.model.get('resolution') :
+ this.contract.get('disputeResolution');
+
+ if (!data) {
+ throw new Error('Unable to create the Dispute Payout view because the resolution ' +
+ 'data object has not been set.');
+ }
+
+ if (this.disputePayout) this.disputePayout.remove();
+ this.disputePayout = this.createChild(DisputePayout, {
+ orderId: this.model.id,
+ initialState: {
+ ...data,
+ showAcceptButton: !this.isCase() && this.model.get('state') === 'DECIDED',
+ },
+ });
+
+ ['buyer', 'vendor', 'moderator'].forEach(type => {
+ this[type].getProfile().done(profile => {
+ const state = {};
+ state[`${type}Name`] = profile.get('name');
+ state[`${type}AvatarHashes`] = profile.get('avatarHashes').toJSON();
+ this.disputePayout.setState(state);
+ });
+ });
+
+ this.listenTo(this.disputeStarted, 'clickResolveDispute',
+ () => this.trigger('clickResolveDispute'));
+
+ this.$subSections.prepend(this.disputePayout.render().el);
+ }
+
+ renderPayForOrder() {
+ if (this.payForOrder) this.payForOrder.remove();
+
+ this.payForOrder = this.createChild(PayForOrder, {
+ balanceRemaining: this.getBalanceRemaining(),
+ paymentAddress: this.paymentAddress,
+ orderId: this.model.id,
+ isModerated: !!this.moderator,
+ });
+
+ this.getCachedEl('.js-payForOrderWrap').html(this.payForOrder.render().el);
+ }
+
+ renderDisputeAcceptanceView() {
+ const data = this.contract.get('disputeAcceptance');
+
+ if (!data) {
+ throw new Error('Unable to create the Dispute Acceptance view because the ' +
+ 'disputeAcceptance data object has not been set.');
+ }
+
+ const closer = data.closedBy ===
+ this.buyer.id ? this.buyer : this.vendor;
+
+ if (this.disputeAcceptance) this.disputeAcceptance.remove();
+ this.disputeAcceptance = this.createChild(DisputeAcceptance, {
+ dataObject: data,
+ initialState: {
+ acceptedByBuyer: closer.id === this.buyer.id,
+ buyerViewing: app.profile.id === this.buyer.id,
+ },
+ });
+
+ closer.getProfile()
+ .done(profile =>
+ this.disputeAcceptance.setState({
+ closerName: profile.get('name'),
+ closerAvatarHashes: profile.get('avatarHashes').toJSON(),
+ }));
+
+ this.$subSections.prepend(this.disputeAcceptance.render().el);
+
+ if (this.model.get('state') === 'RESOLVED' && this.buyer.id === app.profile.id) {
+ this.renderCompleteOrderForm();
+ }
+ }
+
/**
* Will render sub-sections in order based on their timestamp. Exempt from
* this are the Order Details, Payment Details and Accepted sections which
@@ -555,7 +703,7 @@ export default class extends BaseVw {
sections.push({
function: this.renderRefundView,
timestamp:
- (new Date(this.model.get('refundAddressTransaction').timestamp)).getTime(),
+ (new Date(this.model.get('refundAddressTransaction').timestamp)),
});
}
@@ -563,7 +711,7 @@ export default class extends BaseVw {
sections.push({
function: this.renderFulfilledView,
timestamp:
- (new Date(this.contract.get('vendorOrderFulfillment')[0].timestamp)).getTime(),
+ (new Date(this.contract.get('vendorOrderFulfillment')[0].timestamp)),
});
}
@@ -571,7 +719,40 @@ export default class extends BaseVw {
sections.push({
function: this.renderOrderCompleteView,
timestamp:
- (new Date(this.contract.get('buyerOrderCompletion').timestamp)).getTime(),
+ (new Date(this.contract.get('buyerOrderCompletion').timestamp)),
+ });
+ }
+
+ if (this.contract.get('dispute') || this.isCase()) {
+ const timestamp = this.isCase() ?
+ this.model.get('timestamp') :
+ this.contract.get('dispute').timestamp;
+
+ sections.push({
+ function: this.renderDisputeStartedView,
+ timestamp:
+ (new Date(timestamp)),
+ });
+ }
+
+ if (this.contract.get('disputeResolution') ||
+ (this.isCase() && this.model.get('resolution'))) {
+ const timestamp = this.isCase() ?
+ this.model.get('resolution').timestamp :
+ this.contract.get('disputeResolution').timestamp;
+
+ sections.push({
+ function: this.renderDisputePayoutView,
+ timestamp:
+ (new Date(timestamp)),
+ });
+ }
+
+ if (this.contract.get('disputeAcceptance')) {
+ sections.push({
+ function: this.renderDisputeAcceptanceView,
+ timestamp:
+ (new Date(this.contract.get('disputeAcceptance').timestamp)),
});
}
@@ -595,21 +776,13 @@ export default class extends BaseVw {
}
render() {
- const templateData = {
- id: this.model.id,
- shouldShowPayForOrderSection: this.shouldShowPayForOrderSection(),
- isCase: this.isCase(),
- paymentAddress: this.paymentAddress,
- isTestnet: app.testnet,
- ...this.model.toJSON(),
- };
-
- if (this.shouldShowPayForOrderSection()) {
- templateData.balanceRemaining = this.getBalanceRemaining();
- }
-
loadTemplate('modals/orderDetail/summaryTab/summary.html', t => {
- this.$el.html(t(templateData));
+ this.$el.html(t({
+ id: this.model.id,
+ isCase: this.isCase(),
+ isTestnet: app.testnet,
+ ...this.model.toJSON(),
+ }));
this._$copiedToClipboard = null;
if (this.stateProgressBar) this.stateProgressBar.remove();
@@ -625,6 +798,10 @@ export default class extends BaseVw {
});
this.$('.js-orderDetailsWrap').html(this.orderDetails.render().el);
+ if (this.shouldShowPayForOrderSection()) {
+ this.renderPayForOrder();
+ }
+
if (!this.isCase()) {
if (this.payments) this.payments.remove();
this.payments = this.createChild(Payments, {
diff --git a/js/views/modals/purchase/ConfirmWallet.js b/js/views/modals/purchase/ConfirmWallet.js
index 6f2ddc8d7..3c59b42a9 100644
--- a/js/views/modals/purchase/ConfirmWallet.js
+++ b/js/views/modals/purchase/ConfirmWallet.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import $ from 'jquery';
import app from '../../../app';
import loadTemplate from '../../../utils/loadTemplate';
@@ -10,6 +11,12 @@ export default class extends BaseVw {
throw new Error('Please provide a display currency.');
}
+ if (typeof options.amount !== 'number' &&
+ typeof options.amount !== 'function') {
+ throw new Error('The amount should be provided as a number or a function ' +
+ 'that returns one.');
+ }
+
super(options);
this.options = options;
this.fee = false;
@@ -50,6 +57,10 @@ export default class extends BaseVw {
this.trigger('walletConfirm');
}
+ get amount() {
+ return _.result(this.options, 'amount');
+ }
+
remove() {
this.fetchEstimatedFee.abort();
super.remove();
@@ -59,8 +70,7 @@ export default class extends BaseVw {
loadTemplate('modals/purchase/confirmWallet.html', (t) => {
this.$el.html(t({
displayCurrency: this.options.displayCurrency,
- amount: this.options.amount,
- amountBTC: this.options.amountBTC,
+ amount: this.amount,
confirmedAmount: app.walletBalance.get('confirmed'),
fee: this.fee,
}));
diff --git a/js/views/modals/purchase/Payment.js b/js/views/modals/purchase/Payment.js
index 2cc203c67..450d6f833 100644
--- a/js/views/modals/purchase/Payment.js
+++ b/js/views/modals/purchase/Payment.js
@@ -1,29 +1,44 @@
+/*
+ This view is also used by the Order Detail overlay. If you make any changes, please
+ ensure they are compatible with both the Purchase and Order Detail flows.
+*/
+
import $ from 'jquery';
import app from '../../../app';
import loadTemplate from '../../../utils/loadTemplate';
-import { convertAndFormatCurrency, integerToDecimal } from '../../../utils/currency';
+import { formatCurrency, integerToDecimal } from '../../../utils/currency';
import { getSocket } from '../../../utils/serverConnect';
import BaseVw from '../../baseVw';
import ConfirmWallet from './ConfirmWallet';
import qr from 'qr-encode';
import { clipboard, remote } from 'electron';
-import Purchase from '../../../models/purchase/Purchase';
-import Order from '../../../models/purchase/Order';
import { spend } from '../../../models/wallet/Spend';
import { openSimpleMessage } from '../../modals/SimpleMessage';
export default class extends BaseVw {
constructor(options = {}) {
- if (!options.model || !options.model instanceof Purchase) {
- throw new Error('Please provide a purchase model.');
+ if (typeof options.balanceRemaining !== 'number') {
+ throw new Error('Please provide the balance remaining in BTC as a number.');
+ }
+
+ if (!options.paymentAddress) {
+ throw new Error('Please provide the payment address.');
+ }
+
+ if (!options.orderId) {
+ throw new Error('Please provide an orderId.');
}
- if (!options.order || !options.model instanceof Order) {
- throw new Error('Please provide an order model.');
+ if (typeof options.isModerated !== 'boolean') {
+ throw new Error('Please provide a boolean indicating whether the order is moderated.');
}
super(options);
this.options = options;
+ this._balanceRemaining = options.balanceRemaining;
+ this.paymentAddress = options.paymentAddress;
+ this.orderId = options.orderId;
+ this.isModerated = options.isModerated;
this.boundOnDocClick = this.onDocumentClick.bind(this);
$(document).on('click', this.boundOnDocClick);
@@ -34,15 +49,34 @@ export default class extends BaseVw {
// listen for a payment socket message, to react to payments from all sources
if (e.jsonData.notification && e.jsonData.notification.payment) {
const payment = e.jsonData.notification.payment;
- if (integerToDecimal(payment.fundingTotal, true) >= this.model.get('amount') &&
- payment.orderId === this.model.get('orderId')) {
- this.trigger('walletPaymentComplete', payment);
+ if (payment.orderId === this.orderId) {
+ const amount = integerToDecimal(payment.fundingTotal, true);
+ if (amount >= this.balanceRemaining) {
+ this.trigger('walletPaymentComplete', payment);
+ } else {
+ // Ensure the resulting balance has a maximum of 8 decimal places with not
+ // trailing zeros.
+ this.balanceRemaining = parseFloat((this.balanceRemaining - amount).toFixed(8));
+ }
}
}
});
}
}
+ set balanceRemaining(amount) {
+ if (amount !== this._balanceRemaining) {
+ this._balanceRemaining = amount;
+ this.confirmWallet.render();
+ this.$amountDueLine.html(this.amountDueLine);
+ this.$qrCodeImg.attr('src', this.qrDataUri);
+ }
+ }
+
+ get balanceRemaining() {
+ return this._balanceRemaining;
+ }
+
className() {
return 'pending';
}
@@ -75,8 +109,8 @@ export default class extends BaseVw {
this.$confirmWalletConfirm.addClass('processing');
spend({
- address: this.model.get('paymentAddress'),
- amount: this.model.get('amount'),
+ address: this.paymentAddress,
+ amount: this.balanceRemaining,
currency: 'BTC',
})
.fail(jqXhr => {
@@ -91,14 +125,14 @@ export default class extends BaseVw {
}
clickPayFromAlt() {
- const amount = this.model.get('amount');
+ const amount = this.balanceRemaining;
const shapeshiftURL = `https://shapeshift.io/shifty.html?destination=${this.payURL}&output=BTC&apiKey=6e9fbc30b836f85d339b84f3b60cade3f946d2d49a14207d5546895ecca60233b47ec67304cdcfa06e019231a9d135a7965ae50de0a1e68d6ec01b8e57f2b812&amount=${amount}`;
const shapeshiftWin = new remote.BrowserWindow({ width: 700, height: 500, frame: true });
shapeshiftWin.loadURL(shapeshiftURL);
}
copyAmount() {
- clipboard.writeText(String(this.model.get('amount')));
+ clipboard.writeText(String(this.balanceRemaining));
this.$copyAmount.addClass('active');
if (this.hideCopyAmountTimer) {
@@ -109,7 +143,7 @@ export default class extends BaseVw {
}
copyAddress() {
- clipboard.writeText(String(this.model.get('paymentAddress')));
+ clipboard.writeText(String(this.paymentAddress));
this.$copyAddress.addClass('active');
if (this.hideCopyAddressTimer) {
@@ -119,6 +153,16 @@ export default class extends BaseVw {
() => this.$copyAddress.removeClass('active'), 3000);
}
+ get amountDueLine() {
+ return app.polyglot.t('purchase.pendingSection.pay',
+ { amountBTC: formatCurrency(this.balanceRemaining, 'BTC') });
+ }
+
+ get qrDataUri() {
+ const btcURL = `bitcoin:${this.paymentAddress}?amount=${this.balanceRemaining}`;
+ return qr(btcURL, { type: 6, size: 5, level: 'Q' });
+ }
+
get $confirmWallet() {
return this._$confirmWallet ||
(this._$confirmWallet = this.$('.js-confirmWallet'));
@@ -139,6 +183,16 @@ export default class extends BaseVw {
(this._$confirmWalletConfirm = this.$('.js-confirmWalletConfirm'));
}
+ get $amountDueLine() {
+ return this._$amountDueLine ||
+ (this._$amountDueLine = this.$('.js-amountDueLine'));
+ }
+
+ get $qrCodeImg() {
+ return this._$qrCodeImg ||
+ (this._$qrCodeImg = this.$('.js-qrCodeImg'));
+ }
+
remove() {
$(document).off('click', this.boundOnDocClick);
super.remove();
@@ -146,21 +200,16 @@ export default class extends BaseVw {
render() {
const displayCurrency = app.settings.get('localCurrency');
- const amount = this.model.get('amount');
- const amountBTC = amount ? convertAndFormatCurrency(amount, 'BTC', 'BTC') : 0;
-
- const btcURL = `bitcoin:${this.model.get('paymentAddress')}?amount=${amount}`;
loadTemplate('modals/purchase/payment.html', (t) => {
loadTemplate('walletIcon.svg', (walletIconTmpl) => {
this.$el.html(t({
- ...this.model.toJSON(),
displayCurrency,
- amount,
- amountBTC,
- qrDataUri: qr(btcURL, { type: 6, size: 5, level: 'Q' }),
+ amountDueLine: this.amountDueLine,
+ paymentAddress: this.paymentAddress,
+ qrDataUri: this.qrDataUri,
walletIconTmpl,
- moderator: this.options.order.get('moderator'),
+ isModerated: this.isModerated,
}));
});
@@ -168,14 +217,15 @@ export default class extends BaseVw {
this._$copyAmount = null;
this._$copyAddress = null;
this._$confirmWalletConfirm = null;
+ this._$amountDueLine = null;
+ this._$qrCodeImg = null;
// remove old view if any on render
if (this.confirmWallet) this.confirmWallet.remove();
// add the confirmWallet view
this.confirmWallet = this.createChild(ConfirmWallet, {
displayCurrency,
- amount,
- amountBTC,
+ amount: () => this.balanceRemaining,
});
this.listenTo(this.confirmWallet, 'walletCancel', () => this.walletCancel());
this.listenTo(this.confirmWallet, 'walletConfirm', () => this.walletConfirm());
diff --git a/js/views/modals/purchase/Purchase.js b/js/views/modals/purchase/Purchase.js
index ba4359b19..00bbd94d7 100644
--- a/js/views/modals/purchase/Purchase.js
+++ b/js/views/modals/purchase/Purchase.js
@@ -255,7 +255,15 @@ export default class extends BaseModal {
this.updatePageState('pending');
this.actionBtn.render();
this.purchase.set(this.purchase.parse(data));
- this.payment.render();
+ this.payment = this.createChild(Payment, {
+ balanceRemaining: this.purchase.get('amount'),
+ paymentAddress: this.purchase.get('paymentAddress'),
+ orderId: this.purchase.get('orderId'),
+ isModerated: !!this.order.get('moderator'),
+ });
+ this.listenTo(this.payment, 'walletPaymentComplete',
+ (pmtCompleteData => this.completePurchase(pmtCompleteData)));
+ this.$('.js-pending').append(this.payment.render().el);
})
.fail((jqXHR) => {
if (jqXHR.statusText === 'abort') return;
@@ -462,13 +470,7 @@ export default class extends BaseModal {
// remove old view if any on render
if (this.payment) this.payment.remove();
- // add the pending view
- this.payment = this.createChild(Payment, {
- model: this.purchase,
- order: this.order,
- });
- this.listenTo(this.payment, 'walletPaymentComplete', (data => this.completePurchase(data)));
- this.$('.js-pending').append(this.payment.render().el);
+ // pending view will be added once the purchase goes through
// remove old view if any on render
if (this.complete) this.complete.remove();
diff --git a/js/views/transactions/Transactions.js b/js/views/transactions/Transactions.js
index 459b4b953..1a029429b 100644
--- a/js/views/transactions/Transactions.js
+++ b/js/views/transactions/Transactions.js
@@ -159,6 +159,24 @@ export default class extends baseVw {
this.stopListening(orderDetail, 'close', onClose);
});
+ // On any changes to the order / case detail model state, we'll update the
+ // state in the correponding model in the respective collection driving
+ // the transaction table.
+ this.listenTo(model, 'change:state', (md, state) => {
+ let col = this.purchasesCol;
+
+ if (type === 'sale') {
+ col = this.salesCol;
+ } else if (type === 'case') {
+ col = this.casesCol;
+ }
+
+ const collectionMd = col.get(model.id);
+ if (collectionMd) {
+ collectionMd.set('state', state);
+ }
+ });
+
return orderDetail;
}
@@ -197,37 +215,37 @@ export default class extends baseVw {
{
id: 'filterRefunded',
text: app.polyglot.t('transactions.filters.refunded'),
- checked: this.salesPurchasesDefaultFilter.states.indexOf(8) > -1,
+ checked: this.salesPurchasesDefaultFilter.states.indexOf(9) > -1,
className: 'filter',
targetState: [9],
},
{
id: 'filterDisputeOpen',
text: app.polyglot.t('transactions.filters.disputeOpen'),
- checked: this.salesPurchasesDefaultFilter.states.indexOf(5) > -1,
+ checked: this.salesPurchasesDefaultFilter.states.indexOf(10) > -1,
className: 'filter',
targetState: [10],
},
{
id: 'filterDisputePending',
text: app.polyglot.t('transactions.filters.disputePending'),
- checked: this.salesPurchasesDefaultFilter.states.indexOf(6) > -1,
+ checked: this.salesPurchasesDefaultFilter.states.indexOf(11) > -1,
className: 'filter',
targetState: [11],
},
{
id: 'filterDisputeClosed',
text: app.polyglot.t('transactions.filters.disputeClosed'),
- checked: this.salesPurchasesDefaultFilter.states.indexOf(7) > -1,
+ checked: this.salesPurchasesDefaultFilter.states.indexOf(12) > -1,
className: 'filter',
targetState: [12],
},
{
id: 'filterCompleted',
text: app.polyglot.t('transactions.filters.completed'),
- checked: this.salesPurchasesDefaultFilter.states.indexOf(4) > -1 ||
- this.salesPurchasesDefaultFilter.states.indexOf(9) > -1 ||
- this.salesPurchasesDefaultFilter.states.indexOf(10) > -1,
+ checked: this.salesPurchasesDefaultFilter.states.indexOf(6) > -1 ||
+ this.salesPurchasesDefaultFilter.states.indexOf(7) > -1 ||
+ this.salesPurchasesDefaultFilter.states.indexOf(8) > -1,
className: 'filter',
targetState: [6, 7, 8],
},
@@ -238,7 +256,7 @@ export default class extends baseVw {
return {
search: '',
sortBy: 'UNREAD',
- states: [10, 11, 12],
+ states: [10, 12],
};
}
@@ -247,21 +265,14 @@ export default class extends baseVw {
{
id: 'filterDisputeOpen',
text: app.polyglot.t('transactions.filters.disputeOpen'),
- checked: this.salesPurchasesDefaultFilter.states.indexOf(5) > -1,
+ checked: this.salesPurchasesDefaultFilter.states.indexOf(10) > -1,
className: 'filter',
targetState: [10],
},
- {
- id: 'filterDisputePending',
- text: app.polyglot.t('transactions.filters.disputePending'),
- checked: this.salesPurchasesDefaultFilter.states.indexOf(6) > -1,
- className: 'filter',
- targetState: [11],
- },
{
id: 'filterDisputeClosed',
text: app.polyglot.t('transactions.filters.disputeClosed'),
- checked: this.salesPurchasesDefaultFilter.states.indexOf(7) > -1,
+ checked: this.salesPurchasesDefaultFilter.states.indexOf(12) > -1,
className: 'filter',
targetState: [12],
},
@@ -411,11 +422,14 @@ export default class extends baseVw {
data: JSON.stringify(profilesToFetch),
dataType: 'json',
contentType: 'application/json',
- }).done((data) => {
+ }).done(() => {
if (this.socket) {
this.listenTo(this.socket, 'message', (e) => {
- if (e.jsonData.id === data.id) {
- this.profileDeferreds[e.jsonData.peerId].resolve(new Profile(e.jsonData.profile));
+ // if (!e.jsonData.peerId) return;
+
+ if (this.profileDeferreds[e.jsonData.peerId]) {
+ this.profileDeferreds[e.jsonData.peerId].resolve(new Profile(e.jsonData.profile,
+ { parse: true }));
}
});
}
diff --git a/styles/_theme.scss b/styles/_theme.scss
index 8cac37836..da1d6f019 100644
--- a/styles/_theme.scss
+++ b/styles/_theme.scss
@@ -427,4 +427,10 @@ input[type=range][class~="clrS"] {
color: #64452c;
}
}
-}
+
+ .disputeOpenedBadge, .statusIconCol {
+ .ion-alert-circled {
+ color: #ed732a;
+ }
+ }
+}
\ No newline at end of file
diff --git a/styles/components/_containers.scss b/styles/components/_containers.scss
index 6f05549a8..742831e82 100644
--- a/styles/components/_containers.scss
+++ b/styles/components/_containers.scss
@@ -286,6 +286,17 @@
}
}
+.arrowBoxBottom {
+ @extend .arrowBox;
+
+ &::before {
+ bottom: 1px;
+ left: 50%;
+ transform: translate(-25%, 50%) rotate(-135deg);
+ -webkit-clip-path: polygon(0 0, 0 100%, 100% 0);
+ }
+}
+
.toolTip {
position: relative;
cursor: pointer;
diff --git a/styles/components/_stateProgressBar.scss b/styles/components/_stateProgressBar.scss
index 7225fbc38..d3fc4c5a5 100644
--- a/styles/components/_stateProgressBar.scss
+++ b/styles/components/_stateProgressBar.scss
@@ -64,8 +64,14 @@ $borderWidth: 1px;
border-width: 1px;
border-radius: 50%;
font-size: 16px;
- padding: 5px;
z-index: 1;
+ width: 25px;
+ height: 25px;
+ text-align: center;
+
+ .ion-alert-circled::before {
+ padding-top: 3px;
+ }
}
&.active {
diff --git a/styles/modules/modals/_orderDetail.scss b/styles/modules/modals/_orderDetail.scss
index e5601aa48..043e50c25 100644
--- a/styles/modules/modals/_orderDetail.scss
+++ b/styles/modules/modals/_orderDetail.scss
@@ -5,8 +5,8 @@
padding: $padSm;
.avatar {
- width: 24px;
- height: 24px;
+ width: $thumbTn;
+ height: $thumbTn;
float: left;
}
}
@@ -298,19 +298,25 @@
&::before {
padding-top: 7px;
- }
+ }
+ }
+
+ &.ion-alert-circled {
+ font-size: 17px;
+
+ &::before {
+ padding-top: 8px;
+ }
}
}
}
.avatarCol {
flex-shrink: 0;
- width: 35px;
- height: 35px;
+ width: 38px;
+ height: 38px;
border-width: 2px;
border-style: solid;
- width: 38px;
- height: 38px;
}
.orderDetails {
@@ -337,7 +343,7 @@
.refundConfirm {
left: 50%;
transform: translateX(-50%);
- top: 39px;
+ top: 39px;
}
}
@@ -375,16 +381,79 @@
}
}
}
+
+ .disputePayoutEvent {
+ .avatarCol {
+ position: relative;
+ top: -2px;
+ }
+
+ .acceptPayoutConfirm {
+ left: 50%;
+ transform: translateX(-50%);
+ top: 45px;
+ }
+ }
+
+ .payForOrderWrap {
+ &:empty {
+ display: none;
+ }
+ }
}
- .fulfillOrderTab {
+ .fulfillOrderTab, .disputeOrderTab, .resolveDisputeTab {
hr {
margin-left: $padLg;
margin-right: $padLg;
}
-
+
.buttonBar {
padding: $padMd $padLg $padLg;
}
}
+
+ .disputeOrderTab, .resolveDisputeTab {
+ .avatar {
+ width: $thumbTn;
+ height: $thumbTn;
+ }
+ }
+
+ .resolveDisputeTab {
+ .resolveConfirm {
+ bottom: 45px;
+ left: 50%;
+ transform: translateX(-50%);
+ }
+
+ .inputBuyerAmountWrap, .inputVendorAmountWrap {
+ position: relative;
+ max-width: 175px;
+
+ &::after {
+ content: '%';
+ position: absolute;
+ right: 15px;
+ top: 20px;
+ transform: translateY(-50%);
+ }
+
+ input {
+ padding-left: 45px;
+ padding-right: 40px;
+ }
+
+ .avatar {
+ position: absolute;
+ top: 10px;
+ left: 13px;
+ }
+ }
+
+ label[for=resolveDisputeBuyerAmount],
+ label[for=resolveDisputeVendorAmount] {
+ line-height: 2rem;
+ }
+ }
}
\ No newline at end of file