From dc29b8e99d8e1d96ea19b24ff6915721e00ff06b Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Sun, 6 Jan 2019 16:31:00 -0600 Subject: [PATCH 01/15] feat: checkout updates to match multiple payments breaking changes on API --- .../CheckoutActions/CheckoutActions.js | 147 ++-- .../OrderFulfillmentGroup.js | 10 +- .../OrderFulfillmentGroup.test.js | 8 +- .../OrderFulfillmentGroups.js | 41 - .../OrderFulfillmentGroups.test.js | 87 -- .../OrderFulfillmentGroups.test.js.snap | 768 ------------------ .../OrderFulfillmentGroups/index.js | 1 - src/components/OrderSummary/OrderSummary.js | 16 +- .../OrderSummary/OrderSummary.test.js | 8 +- src/components/StripePaymentCheckoutAction.js | 225 +++++ src/containers/cart/fragments.gql | 3 + src/containers/order/fragments.gql | 56 +- src/containers/order/mutations.gql | 6 +- src/containers/order/withPlaceStripeOrder.js | 74 -- src/lib/stores/CartStore.js | 15 +- src/pages/checkout.js | 9 +- src/pages/checkoutComplete.js | 21 +- 17 files changed, 356 insertions(+), 1139 deletions(-) delete mode 100644 src/components/OrderFulfillmentGroups/OrderFulfillmentGroups.js delete mode 100644 src/components/OrderFulfillmentGroups/OrderFulfillmentGroups.test.js delete mode 100644 src/components/OrderFulfillmentGroups/__snapshots__/OrderFulfillmentGroups.test.js.snap delete mode 100644 src/components/OrderFulfillmentGroups/index.js create mode 100644 src/components/StripePaymentCheckoutAction.js delete mode 100644 src/containers/order/withPlaceStripeOrder.js diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 2dfed47719..389cab9b19 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -1,14 +1,13 @@ import React, { Fragment, Component } from "react"; import PropTypes from "prop-types"; -import { inject, observer } from "mobx-react"; +import { observer } from "mobx-react"; import isEqual from "lodash.isequal"; import Actions from "@reactioncommerce/components/CheckoutActions/v1"; import ShippingAddressCheckoutAction from "@reactioncommerce/components/ShippingAddressCheckoutAction/v1"; import FulfillmentOptionsCheckoutAction from "@reactioncommerce/components/FulfillmentOptionsCheckoutAction/v1"; -import StripePaymentCheckoutAction from "@reactioncommerce/components/StripePaymentCheckoutAction/v1"; +import StripePaymentCheckoutAction from "components/StripePaymentCheckoutAction"; import FinalReviewCheckoutAction from "@reactioncommerce/components/FinalReviewCheckoutAction/v1"; import withCart from "containers/cart/withCart"; -import withPlaceStripeOrder from "containers/order/withPlaceStripeOrder"; import withAddressValidation from "containers/address/withAddressValidation"; import Dialog from "@material-ui/core/Dialog"; import PageLoading from "components/PageLoading"; @@ -19,6 +18,7 @@ import trackCheckout from "lib/tracking/trackCheckout"; import trackOrder from "lib/tracking/trackOrder"; import trackCheckoutStep from "lib/tracking/trackCheckoutStep"; import { isShippingAddressSet } from "lib/utils/cartUtils"; +import { placeOrder } from "../../containers/order/mutations.gql"; const { CHECKOUT_STARTED, @@ -30,8 +30,6 @@ const { @withAddressValidation @withCart -@withPlaceStripeOrder -@inject("authStore") @track() @observer export default class CheckoutActions extends Component { @@ -45,14 +43,14 @@ export default class CheckoutActions extends Component { items: PropTypes.array }), cartStore: PropTypes.shape({ - stripeToken: PropTypes.object + checkoutPaymentInputData: PropTypes.object, + setCheckoutPaymentInputData: PropTypes.func }), checkoutMutations: PropTypes.shape({ - // onUpdateFulfillmentOptionsForGroup: PropTypes.func.isRequired, onSetFulfillmentOption: PropTypes.func.isRequired, onSetShippingAddress: PropTypes.func.isRequired }), - placeOrderWithStripeCard: PropTypes.func.isRequired + orderEmailAddress: PropTypes.string.isRequired }; state = { @@ -106,27 +104,22 @@ export default class CheckoutActions extends Component { @trackOrder() trackOrder() {} - buildData = (data) => { - const { step, shipping_method = null, payment_method = null, action } = data; // eslint-disable-line camelcase - - return { - action, - payment_method, // eslint-disable-line camelcase - shipping_method, // eslint-disable-line camelcase - step - }; - }; + buildData = ({ step, action }) => ({ + action, + payment_method: this.paymentMethod, // eslint-disable-line camelcase + shipping_method: this.shippingMethod, // eslint-disable-line camelcase + step + }); get shippingMethod() { const { checkout: { fulfillmentGroups } } = this.props.cart; - const shippingMethod = fulfillmentGroups[0].selectedFulfillmentOption.fulfillmentMethod.displayName; - - return shippingMethod; + const { selectedFulfillmentOption } = fulfillmentGroups[0]; + return selectedFulfillmentOption ? selectedFulfillmentOption.fulfillmentMethod.displayName : null; } get paymentMethod() { - const { stripeToken: { token: { card } } } = this.props.cartStore; - return card.brand; + const { checkoutPaymentInputData } = this.props.cartStore; + return checkoutPaymentInputData ? checkoutPaymentInputData.payment.method : null; } setShippingAddress = async (address) => { @@ -174,28 +167,15 @@ export default class CheckoutActions extends Component { const { data, error } = await onSetFulfillmentOption(fulfillmentOption); if (data && !error) { // track successfully setting a shipping method - this.trackAction({ - step: 2, - shipping_method: this.shippingMethod, // eslint-disable-line camelcase - payment_method: null, // eslint-disable-line camelcase - action: CHECKOUT_STEP_COMPLETED - }); + this.trackAction(this.buildData({ action: CHECKOUT_STEP_COMPLETED, step: 2 })); // The next step will automatically be expanded, so lets track that - this.trackAction({ - step: 3, - shipping_method: this.shippingMethod, // eslint-disable-line camelcase - payment_method: null, // eslint-disable-line camelcase - action: CHECKOUT_STEP_VIEWED - }); + this.trackAction(this.buildData({ action: CHECKOUT_STEP_VIEWED, step: 3 })); } }; - setPaymentMethod = (stripeToken) => { - const { cartStore } = this.props; - - // Store stripe token in MobX store - cartStore.setStripeToken(stripeToken); + setPaymentMethod = (paymentInputData) => { + this.props.cartStore.setCheckoutPaymentInputData(paymentInputData); this.setState({ hasPaymentError: false, @@ -205,26 +185,17 @@ export default class CheckoutActions extends Component { }); // Track successfully setting a payment method - this.trackAction({ - step: 3, - shipping_method: this.shippingMethod, // eslint-disable-line camelcase - payment_method: this.paymentMethod, // eslint-disable-line camelcase - action: PAYMENT_INFO_ENTERED - }); + this.trackAction(this.buildData({ action: PAYMENT_INFO_ENTERED, step: 3 })); // The next step will automatically be expanded, so lets track that - this.trackAction({ - step: 4, - shipping_method: this.shippingMethod, // eslint-disable-line camelcase - payment_method: this.paymentMethod, // eslint-disable-line camelcase - action: CHECKOUT_STEP_VIEWED - }); + this.trackAction(this.buildData({ action: CHECKOUT_STEP_VIEWED, step: 4 })); }; buildOrder = async () => { - const { cart, cartStore } = this.props; + const { cart, cartStore, orderEmailAddress } = this.props; const cartId = cartStore.hasAccountCart ? cartStore.accountCartId : cartStore.anonymousCartId; - const { checkout, email, shop } = cart; + const { checkout } = cart; + const fulfillmentGroups = checkout.fulfillmentGroups.map((group) => { const { data } = group; const { selectedFulfillmentOption } = group; @@ -240,7 +211,7 @@ export default class CheckoutActions extends Component { data, items, selectedFulfillmentMethodId: selectedFulfillmentOption.fulfillmentMethod._id, - shopId: shop._id, + shopId: group.shop._id, totalPrice: checkout.summary.total.amount, type: group.type }; @@ -248,35 +219,45 @@ export default class CheckoutActions extends Component { const order = { cartId, - currencyCode: shop.currency.code, - email, + currencyCode: cart.currencyCode, + email: orderEmailAddress, fulfillmentGroups, - shopId: shop._id + shopId: cart.shop._id }; return this.setState({ isPlacingOrder: true }, () => this.placeOrder(order)); }; placeOrder = async (order) => { - const { authStore, cartStore, placeOrderWithStripeCard } = this.props; + const { cartStore, client: apolloClient } = this.props; + const { payment } = cartStore.checkoutPaymentInputData || {}; try { - const { data } = await placeOrderWithStripeCard(order); - const { placeOrderWithStripeCardPayment: { orders, token } } = data; - - this.trackAction({ - step: 4, - shipping_method: this.shippingMethod, // eslint-disable-line camelcase - payment_method: this.paymentMethod, // eslint-disable-line camelcase - action: CHECKOUT_STEP_COMPLETED + const amount = order.fulfillmentGroups.reduce((sum, group) => sum + group.totalPrice, 0); + const { data } = await apolloClient.mutate({ + mutation: placeOrder, + variables: { + input: { + order, + payments: [{ ...payment, amount }] + } + } }); - // Clear anonymous cart - if (!authStore.isAuthenticated) { - cartStore.clearAnonymousCartCredentials(); - } + // Placing the order was successful, so we should clear the + // anonymous cart credentials from cookie since it will be + // deleted on the server. + cartStore.clearAnonymousCartCredentials(); + + // Also destroy the collected and cached payment input + cartStore.setCheckoutPaymentInputData(null); + + const { placeOrder: { orders, token } } = data; + + this.trackAction(this.buildData({ action: CHECKOUT_STEP_COMPLETED, step: 4 })); this.trackOrder({ action: ORDER_COMPLETED, orders }); + // Send user to order confirmation page Router.pushRoute("checkoutComplete", { orderId: orders[0].referenceId, token }); } catch (error) { @@ -307,12 +288,10 @@ export default class CheckoutActions extends Component { }; render() { - if (!this.props.cart) { - return null; - } + const { addressValidation, addressValidationResults, cart, cartStore } = this.props; + if (!cart) return null; - const { addressValidation, addressValidationResults, cartStore: { stripeToken } } = this.props; - const { checkout: { fulfillmentGroups, summary }, items } = this.props.cart; + const { checkout: { fulfillmentGroups, summary }, items } = cart; const { actionAlerts, hasPaymentError } = this.state; const shippingAddressSet = isShippingAddressSet(fulfillmentGroups); const fulfillmentGroup = fulfillmentGroups[0]; @@ -327,17 +306,7 @@ export default class CheckoutActions extends Component { }; } - let paymentData = null; - if (stripeToken) { - const { billingAddress, token: { card } } = stripeToken; - const displayName = `${card.brand} ending in ${card.last4}`; - paymentData = { - data: { - billingAddress, - displayName - } - }; - } + const paymentData = cartStore.checkoutPaymentInputData; // Order summary const { fulfillmentTotal, itemTotal, taxTotal, total } = summary; @@ -383,12 +352,12 @@ export default class CheckoutActions extends Component { activeLabel: "Enter payment information", completeLabel: "Payment information", incompleteLabel: "Payment information", - status: stripeToken && !hasPaymentError ? "complete" : "incomplete", + status: paymentData && !hasPaymentError ? "complete" : "incomplete", component: StripePaymentCheckoutAction, onSubmit: this.setPaymentMethod, props: { alert: actionAlerts["3"], - payment: paymentData + paymentData } }, { diff --git a/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.js b/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.js index ebf271c35a..61738cda21 100644 --- a/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.js +++ b/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.js @@ -41,10 +41,7 @@ class OrderFulfillmentGroup extends Component { loadMoreCartItems: PropTypes.func, onChangeCartItemsQuantity: PropTypes.func, onRemoveCartItems: PropTypes.func, - shop: PropTypes.shape({ - name: PropTypes.string.isRequired, - description: PropTypes.string - }) + payments: PropTypes.arrayOf(PropTypes.object) } static defaultProps = { @@ -133,8 +130,9 @@ class OrderFulfillmentGroup extends Component { } render() { - const { classes, fulfillmentGroup } = this.props; + const { classes, fulfillmentGroup, payments } = this.props; const { fulfillmentMethod } = fulfillmentGroup.selectedFulfillmentOption; + return (
@@ -152,7 +150,7 @@ class OrderFulfillmentGroup extends Component { {this.renderFulfillmentInfo()}
- +
); diff --git a/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.test.js b/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.test.js index 1513e9f6fb..5f35e4b6c8 100644 --- a/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.test.js +++ b/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.test.js @@ -56,9 +56,6 @@ const testFulfillmentGroup = { } ] }, - payment: { - displayName: "Example Payment" - }, selectedFulfillmentOption: { fulfillmentMethod: { displayName: "Free Shipping", @@ -67,12 +64,17 @@ const testFulfillmentGroup = { } }; +const testPayments = [{ + displayName: "Example Payment" +}]; + test("basic snapshot", () => { const component = renderer.create(( diff --git a/src/components/OrderFulfillmentGroups/OrderFulfillmentGroups.js b/src/components/OrderFulfillmentGroups/OrderFulfillmentGroups.js deleted file mode 100644 index 9200bb6029..0000000000 --- a/src/components/OrderFulfillmentGroups/OrderFulfillmentGroups.js +++ /dev/null @@ -1,41 +0,0 @@ -import React, { Component, Fragment } from "react"; -import PropTypes from "prop-types"; -import OrderFulfillmentGroup from "components/OrderFulfillmentGroup"; - -class OrderFulfillmentGroups extends Component { - static propTypes = { - classes: PropTypes.object, - order: PropTypes.shape({ - fulfillmentGroups: PropTypes.arrayOf(PropTypes.object) - }), - shop: PropTypes.shape({ - name: PropTypes.string.isRequired, - description: PropTypes.string - }) - } - - renderFulfillmentGroups() { - const { order } = this.props; - - if (order && Array.isArray(order.fulfillmentGroups)) { - return order.fulfillmentGroups.map((fulfillmentGroup, index) => ( - - )); - } - - return null; - } - - render() { - return ( - - {this.renderFulfillmentGroups()} - - ); - } -} - -export default OrderFulfillmentGroups; diff --git a/src/components/OrderFulfillmentGroups/OrderFulfillmentGroups.test.js b/src/components/OrderFulfillmentGroups/OrderFulfillmentGroups.test.js deleted file mode 100644 index 2fa32a260c..0000000000 --- a/src/components/OrderFulfillmentGroups/OrderFulfillmentGroups.test.js +++ /dev/null @@ -1,87 +0,0 @@ -import React from "react"; -import renderer from "react-test-renderer"; -import { MuiThemeProvider } from "@material-ui/core/styles"; -import theme from "custom/reactionTheme"; -import { ComponentsProvider } from "@reactioncommerce/components-context"; -import components from "custom/componentsContext"; -import OrderFulfillmentGroups from "./OrderFulfillmentGroups"; - -const testOrder = { - fulfillmentGroups: [ - { - summary: { - itemTotal: { - displayAmount: "$118" - }, - total: { - displayAmount: "$118" - } - }, - items: { - nodes: [ - { - _id: "123", - attributes: [{ label: "Color", value: "Red" }, { label: "Size", value: "Medium" }], - compareAtPrice: { - displayAmount: "$45.00" - }, - currentQuantity: 3, - imageURLs: { - small: "//placehold.it/150", - thumbnail: "//placehold.it/100" - }, - isLowQuantity: true, - price: { - displayAmount: "$20.00" - }, - productSlug: "product-slug", - productVendor: "Patagonia", - title: "A Great Product", - quantity: 2 - }, - { - _id: "456", - attributes: [{ label: "Color", value: "Black" }, { label: "Size", value: "10" }], - currentQuantity: 500, - imageURLs: { - small: "//placehold.it/150", - thumbnail: "//placehold.it/100" - }, - isLowQuantity: false, - price: { - displayAmount: "$78.00" - }, - productVendor: "Nike", - productSlug: "product-slug", - title: "Another Great Product", - quantity: 1 - } - ] - }, - payment: { - displayName: "Example Payment" - }, - selectedFulfillmentOption: { - fulfillmentMethod: { - displayName: "Free Shipping", - group: "Ground" - } - } - } - ] -}; - -test("basic snapshot", () => { - const component = renderer.create(( - - - - - - )); - - const tree = component.toJSON(); - expect(tree).toMatchSnapshot(); -}); diff --git a/src/components/OrderFulfillmentGroups/__snapshots__/OrderFulfillmentGroups.test.js.snap b/src/components/OrderFulfillmentGroups/__snapshots__/OrderFulfillmentGroups.test.js.snap deleted file mode 100644 index 11e920a861..0000000000 --- a/src/components/OrderFulfillmentGroups/__snapshots__/OrderFulfillmentGroups.test.js.snap +++ /dev/null @@ -1,768 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`basic snapshot 1`] = ` -Array [ - .c0 { - position: relative; - -webkit-align-items: flex-start; - -webkit-box-align: flex-start; - -ms-flex-align: flex-start; - align-items: flex-start; - border-bottom-color: #f5f5f5; - border-bottom-style: solid; - border-bottom-width: 1px; - border-left-color: #f5f5f5; - border-left-style: solid; - border-left-width: 0; - border-right-color: #f5f5f5; - border-right-style: solid; - border-right-width: 0; - border-top-color: #f5f5f5; - border-top-style: solid; - border-top-width: 0; - box-sizing: border-box; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding-bottom: 16px; - padding-left: 0; - padding-right: 0; - padding-top: 16px; - width: 100%; -} - -.c0:first-of-type { - border-top: none; -} - -.c0:last-of-type { - border-bottom: none; -} - -.c0 > * { - box-sizing: border-box; -} - -.c1 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin-left: 16px; - position: relative; - width: 100%; -} - -.c2 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex: 1 1 auto; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - -webkit-flex-wrap: wrap; - -ms-flex-wrap: wrap; - flex-wrap: wrap; -} - -.c3 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex: 1 1 100%; - -ms-flex: 1 1 100%; - flex: 1 1 100%; - -webkit-flex-wrap: wrap; - -ms-flex-wrap: wrap; - flex-wrap: wrap; -} - -.c4 { - -webkit-flex: 1 1 100%; - -ms-flex: 1 1 100%; - flex: 1 1 100%; -} - -.c11 { - position: absolute; - bottom: 16px; - right: 0; - text-align: right; -} - -.c14 { - position: initial; - margin-top: 10px; - bottom: 16px; - right: 0; - text-align: right; -} - -.c15 { - -webkit-font-smoothing: antialiased; - color: #505558; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 14px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .02em; - -moz-letter-spacing: .02em; - -ms-letter-spacing: .02em; - letter-spacing: .02em; - line-height: 1.25; - white-space: pre; -} - -.c16 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 600; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1.5; -} - -.c5 { - -webkit-flex: 0 0 fit; - -ms-flex: 0 0 fit; - flex: 0 0 fit; -} - -.c6 { - -webkit-font-smoothing: antialiased; - color: #505558; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 700; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - margin-top: 0; - margin-bottom: 10px; - margin-left: 0; - margin-right: 0; -} - -.c6 a { - -webkit-font-smoothing: antialiased; - color: #505558; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 700; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - -webkit-text-decoration: none; - text-decoration: none; -} - -.c6 a:focus, -.c6 a:hover { - color: #5d8ea9; -} - -.c8 { - -webkit-font-smoothing: antialiased; - color: #595959; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 14px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .02em; - -moz-letter-spacing: .02em; - -ms-letter-spacing: .02em; - letter-spacing: .02em; - line-height: 1.25; - margin: 0; -} - -.c7 { - margin-bottom: 0.5rem; -} - -.c9 { - -webkit-font-smoothing: antialiased; - color: #595959; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 14px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .02em; - -moz-letter-spacing: .02em; - -ms-letter-spacing: .02em; - letter-spacing: .02em; - line-height: 1.25; - margin: 0; -} - -.c13 { - -webkit-font-smoothing: antialiased; - color: #505558; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 14px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .02em; - -moz-letter-spacing: .02em; - -ms-letter-spacing: .02em; - letter-spacing: .02em; - line-height: 1.25; -} - -.c12 { - -webkit-font-smoothing: antialiased; - color: #bfbfbf; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 14px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .02em; - -moz-letter-spacing: .02em; - -ms-letter-spacing: .02em; - letter-spacing: .02em; - line-height: 1.25; - display: block; -} - -.c10 { - -webkit-font-smoothing: antialiased; - color: #cd3f4c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 14px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .02em; - -moz-letter-spacing: .02em; - -ms-letter-spacing: .02em; - letter-spacing: .02em; - line-height: 1.25; -} - -@media (min-width:992px) { - .c4 { - -webkit-flex: 1 1 100%; - -ms-flex: 1 1 100%; - flex: 1 1 100%; - } -} - -@media (max-width:768px) { - .c11 { - position: absolute; - } -} - -@media (min-width:768px) { - .c11 { - margin-left: 1.5rem; - } -} - -@media (max-width:768px) { - .c14 { - position: initial; - margin-top: 10px; - } -} - -
-
-
-
-

- Free Shipping -

-
-
- -
-
-
-
-
-
-
- - - - - - -
-
-
-
-
-

- - A Great Product - -

-
-

- Patagonia -

-

- Red, Medium -

-

- Quantity: - 2 -

-
-
-
- Only - 3 - in stock -
-
-
-
-
-
-
- - - $45.00 - -
- $20.00 -
-
-
-
- Total ( - 2 - ): -
-
-
-
-
-
- - - - - - -
-
-
-
-
-

- - Another Great Product - -

-
-

- Nike -

-

- Black, 10 -

-

- Quantity: - 1 -

-
-
-
-
-
-
-
-
- -
- $78.00 -
-
-
-
-
-
-
-
, - .c1 { - width: 100%; - border-spacing: 0; - background-color: transparent; - padding-left: 0; - padding-right: 0; - padding-top: 0; - padding-bottom: 0; -} - -.c2 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 0; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; -} - -.c4 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 1px; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; -} - -.c3 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 0; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - text-align: right; -} - -.c5 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 1px; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - text-align: right; -} - -.c6 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 700; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; -} - -.c0 table td { - padding-left: 1rem; - padding-right: 1rem; - border-bottom: none; -} - -
-
-
-
-
-

- Payment Method -

-
-
- -
-
-
-
-
- - - - - - - - - - - - - - - - - - -
- Item total - - $118 -
- Shipping - -
- Tax - - - -
- Order total - - - $118 - -
-
-
-
, -] -`; diff --git a/src/components/OrderFulfillmentGroups/index.js b/src/components/OrderFulfillmentGroups/index.js deleted file mode 100644 index 92ada49167..0000000000 --- a/src/components/OrderFulfillmentGroups/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./OrderFulfillmentGroups"; diff --git a/src/components/OrderSummary/OrderSummary.js b/src/components/OrderSummary/OrderSummary.js index 6275a370fb..c9fd1da08b 100644 --- a/src/components/OrderSummary/OrderSummary.js +++ b/src/components/OrderSummary/OrderSummary.js @@ -47,10 +47,12 @@ class OrderSummary extends Component { }) }) }), - shop: PropTypes.shape({ - name: PropTypes.string.isRequired, - description: PropTypes.string - }) + payments: PropTypes.arrayOf(PropTypes.shape({ + amount: PropTypes.shape({ + displayAmount: PropTypes.string.isRequired + }), + displayName: PropTypes.string.isRequired + })) } renderSummary() { @@ -81,7 +83,7 @@ class OrderSummary extends Component { } render() { - const { classes, fulfillmentGroup } = this.props; + const { classes, payments } = this.props; return (
@@ -91,7 +93,9 @@ class OrderSummary extends Component { {"Payment Method"} - {fulfillmentGroup.payment && fulfillmentGroup.payment.displayName} + {(payments || []).map((payment) => ( + {payment.displayName} + ))}
diff --git a/src/components/OrderSummary/OrderSummary.test.js b/src/components/OrderSummary/OrderSummary.test.js index 9c6bde8e3f..4daad3fd2d 100644 --- a/src/components/OrderSummary/OrderSummary.test.js +++ b/src/components/OrderSummary/OrderSummary.test.js @@ -56,9 +56,6 @@ const testFulfillmentGroup = { } ] }, - payment: { - displayName: "Example Payment" - }, selectedFulfillmentOption: { fulfillmentMethod: { displayName: "Free Shipping", @@ -67,12 +64,17 @@ const testFulfillmentGroup = { } }; +const testPayments = [{ + displayName: "Example Payment" +}]; + test("basic snapshot", () => { const component = renderer.create(( diff --git a/src/components/StripePaymentCheckoutAction.js b/src/components/StripePaymentCheckoutAction.js new file mode 100644 index 0000000000..5426786188 --- /dev/null +++ b/src/components/StripePaymentCheckoutAction.js @@ -0,0 +1,225 @@ +import React, { Component, Fragment } from "react"; +import PropTypes from "prop-types"; +import { withComponents } from "@reactioncommerce/components-context"; +import Fade from "@material-ui/core/Fade"; +import styled from "styled-components"; +import { addTypographyStyles, CustomPropTypes } from "@reactioncommerce/components/utils"; + +const Title = styled.h3` + ${addTypographyStyles("StripePaymentCheckoutAction", "subheadingTextBold")} +`; + +const SecureCaption = styled.div` + ${addTypographyStyles("StripePaymentCheckoutAction", "captionText")} +`; + +const IconLockSpan = styled.span` + display: inline-block; + height: 20px; + width: 20px; +`; + +const Span = styled.span` + vertical-align: super; +`; + +const billingAddressOptions = [{ + id: "1", + label: "Same as shipping address", + value: "same_as_shipping" +}, +{ + id: "2", + label: "Use a different billing address", + value: "use_different_billing_address" +}]; + +class StripePaymentCheckoutAction extends Component { + static renderComplete({ paymentData }) { + return ( +
+ {!!paymentData && paymentData.displayName} +
+ ); + } + + static propTypes = { + /** + * Alert object provides alert into to InlineAlert. + */ + alert: CustomPropTypes.alert, + /** + * If you've set up a components context using + * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) + * (recommended), then this prop will come from there automatically. If you have not + * set up a components context or you want to override one of the components in a + * single spot, you can pass in the components prop directly. + */ + components: PropTypes.shape({ + /** + * Pass either the Reaction AddressForm component or your own component that + * accepts compatible props. + */ + AddressForm: CustomPropTypes.component.isRequired, + /** + * Secured lock icon + */ + iconLock: PropTypes.node, + /** + * A reaction SelectableList component or compatible component. + */ + SelectableList: CustomPropTypes.component.isRequired, + /** + * Pass either the Reaction StripeForm component or your own component that + * accepts compatible props. + */ + StripeForm: CustomPropTypes.component.isRequired, + /** + * Pass either the Reaction InlineAlert component or your own component that + * accepts compatible props. + */ + InlineAlert: CustomPropTypes.component.isRequired + }), + /** + * Is the payment input being saved? + */ + isSaving: PropTypes.bool, + /** + * Label of workflow step + */ + label: PropTypes.string.isRequired, + /** + * When this action's input data switches between being + * ready for saving and not ready for saving, this will + * be called with `true` (ready) or `false` + */ + onReadyForSaveChange: PropTypes.func, + /** + * Called with an object value when this component's `submit` + * method is called. The object has `payment` and `displayName` + * properties, where `payment` is the Payment that should be + * passed to the `placeOrder` mutation. + */ + onSubmit: PropTypes.func, + /** + * Checkout process step number + */ + stepNumber: PropTypes.number.isRequired + }; + + static defaultProps = { + onReadyForSaveChange() { } + }; + + state = { + billingAddressOptionValue: "same_as_shipping" + }; + + componentDidMount() { + const { onReadyForSaveChange } = this.props; + onReadyForSaveChange(false); + } + + _addressForm = null; + + submit = async () => { + const { billingAddressOptionValue } = this.state; + + // If user chooses to use billing address to be the same as shipping, then + // don't submit the billing address form + if (billingAddressOptionValue === "same_as_shipping") { + await this.handleSubmit(); + } else { + await this._addressForm.submit(); + } + } + + handleSubmit = async (value) => { + const { onSubmit } = this.props; + const { token } = await this._stripe.createToken(); + + await onSubmit({ + displayName: `${token.card.brand} ending in ${token.card.last4}`, + payment: { + billingAddress: value, + data: { + stripeTokenId: token.id + }, + method: "stripe_card" + } + }); + } + + handleChange = (values) => { + const { onReadyForSaveChange } = this.props; + const isFilled = Object.keys(values).every((key) => (key === "address2" ? true : values[key] !== null)); + + onReadyForSaveChange(isFilled); + }; + + handleUseNewBillingAddress = (billingAddressOptionValue) => { + this.setState({ billingAddressOptionValue }); + } + + renderBillingAddressForm = () => { + const { components: { AddressForm } } = this.props; + const { billingAddressOptionValue } = this.state; + + // Only render billing address when user chooses to use + // a different billing address + if (billingAddressOptionValue === "same_as_shipping") { + return null; + } + + + return ( + + { + this._addressForm = formEl; + }} + onSubmit={this.handleSubmit} + onChange={this.handleChange} + /> + + ); + } + + render() { + const { + alert, + components: { iconLock, InlineAlert, SelectableList, StripeForm }, + label, + onReadyForSaveChange, + stepNumber + } = this.props; + + const { billingAddressOptionValue } = this.state; + + return ( + + + {stepNumber}. {label} + + {alert ? : ""} + { this._stripe = stripe; }} + /> + + {iconLock} Your Information is private and secure. + + Billing Address + + {this.renderBillingAddressForm()} + + ); + } +} + +export default withComponents(StripePaymentCheckoutAction); diff --git a/src/containers/cart/fragments.gql b/src/containers/cart/fragments.gql index b39e168148..538431050e 100644 --- a/src/containers/cart/fragments.gql +++ b/src/containers/cart/fragments.gql @@ -63,6 +63,9 @@ fragment CartCommon on Cart { displayAmount } } + shop { + _id + } } summary { fulfillmentTotal { diff --git a/src/containers/order/fragments.gql b/src/containers/order/fragments.gql index b1f7b4e1ee..ec72077c28 100644 --- a/src/containers/order/fragments.gql +++ b/src/containers/order/fragments.gql @@ -86,40 +86,6 @@ fragment OrderCommon on Order { variantTitle } } - payment { - _id - amount { - amount - currency { - code - } - displayAmount - } - createdAt - data { - ... on StripeCardPaymentData { - chargeId - billingAddress { - _id - address1 - address2 - city - company - country - fullName - isCommercial - isShippingDefault - phone - postal - region - } - } - } - displayName - method { - name - } - } selectedFulfillmentOption { fulfillmentMethod { _id @@ -167,6 +133,28 @@ fragment OrderCommon on Order { } type } + payments { + _id + amount { + displayAmount + } + billingAddress { + address1 + address2 + city + company + country + fullName + isCommercial + phone + postal + region + } + displayName + method { + name + } + } referenceId shop { _id diff --git a/src/containers/order/mutations.gql b/src/containers/order/mutations.gql index 92757e47fd..40b0dd50b2 100644 --- a/src/containers/order/mutations.gql +++ b/src/containers/order/mutations.gql @@ -1,8 +1,8 @@ #import "./fragments.gql" -# place an order with a stripe payment method -mutation placeOrderWithStripeCardPayment( $input: PlaceOrderWithStripeCardPaymentInput!) { - placeOrderWithStripeCardPayment(input: $input) { +# place an order +mutation placeOrder($input: PlaceOrderInput!) { + placeOrder(input: $input) { orders { ...OrderQueryFragment }, diff --git a/src/containers/order/withPlaceStripeOrder.js b/src/containers/order/withPlaceStripeOrder.js deleted file mode 100644 index 551abb2b47..0000000000 --- a/src/containers/order/withPlaceStripeOrder.js +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { withApollo } from "react-apollo"; -import { inject, observer } from "mobx-react"; -import { placeOrderWithStripeCardPayment } from "./mutations.gql"; - -/** - * withPlaceStripeOrder higher order is used to place an order with a stripe card. - * @name WithPlaceStripeOrder - * @param {React.Component} Component to decorate - * @returns {React.Component} - Component with placeOrderWithStripeCard callback in props - */ -export default (Component) => ( - @withApollo - @inject("authStore", "cartStore", "routingStore") - @observer - class WithPlaceStripeOrder extends React.Component { - static propTypes = { - authStore: PropTypes.shape({ - account: PropTypes.shape({ - emailRecords: PropTypes.arrayOf(PropTypes.shape({ - address: PropTypes.string - })) - }) - }), - cartStore: PropTypes.shape({ - stripeToken: PropTypes.object - }), - client: PropTypes.shape({ - mutate: PropTypes.func.isRequired - }) - } - - handlePlaceOrderWithStripeCard = async (order) => { - const { authStore, cartStore, client: apolloClient } = this.props; - const { fulfillmentGroups } = order; - const { stripeToken: { billingAddress } } = cartStore; - - const accountOrder = Object.assign({}, order); - if (authStore.account.emailRecords) { - const { account: { emailRecords } } = authStore; - if (Array.isArray(emailRecords.slice())) { - accountOrder.email = emailRecords[0].address; - } - // TODO: throw error, complain - } - - const payment = { - // If the users provided a billing address use it, otherwise, use the shipping address - billingAddress: billingAddress || fulfillmentGroups[0].data.shippingAddress, - stripeTokenId: cartStore.stripeToken.token.id - }; - - return apolloClient.mutate({ - mutation: placeOrderWithStripeCardPayment, - variables: { - input: { - order: accountOrder, - payment - } - } - }); - } - - render() { - return ( - - ); - } - } -); diff --git a/src/lib/stores/CartStore.js b/src/lib/stores/CartStore.js index ba1407d599..b0bb868217 100644 --- a/src/lib/stores/CartStore.js +++ b/src/lib/stores/CartStore.js @@ -38,11 +38,11 @@ class CartStore { @observable isReconcilingCarts = false; /** - * The Stripe token that stores encrypted user payment data + * Payment data from the payment action during checkout * * @type Object */ - @observable stripeToken = null; + @observable checkoutPaymentInputData = null; /** * @name setAnonymousCartCredentials @@ -71,9 +71,6 @@ class CartStore { // Remove cookies Cookies.remove(this.ANONYMOUS_CART_ID_KEY_NAME); Cookies.remove(this.ANONYMOUS_CART_TOKEN_KEY_NAME); - - // Clear stripe token - this.stripeToken = null; } } @@ -127,12 +124,8 @@ class CartStore { this.accountCartId = value; } - get stripeToken() { - return this.stripeToken; - } - - @action setStripeToken(value) { - this.stripeToken = value; + @action setCheckoutPaymentInputData(value) { + this.checkoutPaymentInputData = value; } } diff --git a/src/pages/checkout.js b/src/pages/checkout.js index d6f6aa08d6..bcecb43ba3 100644 --- a/src/pages/checkout.js +++ b/src/pages/checkout.js @@ -308,8 +308,7 @@ class Checkout extends Component { } const hasAccount = !!cart.account; - const displayEmail = (hasAccount && Array.isArray(cart.account.emailRecords) && cart.account.emailRecords[0].address) || cart.email; - + const orderEmailAddress = (hasAccount && Array.isArray(cart.account.emailRecords) && cart.account.emailRecords[0].address) || cart.email; return (
@@ -318,10 +317,10 @@ class Checkout extends Component {
- {displayEmail ? ( - + {orderEmailAddress ? ( + ) : null} - +
diff --git a/src/pages/checkoutComplete.js b/src/pages/checkoutComplete.js index 12fa180f7e..4b286e0373 100644 --- a/src/pages/checkoutComplete.js +++ b/src/pages/checkoutComplete.js @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import Helmet from "react-helmet"; import { withStyles } from "@material-ui/core/styles"; import Typography from "@material-ui/core/Typography"; -import OrderFulfillmentGroups from "components/OrderFulfillmentGroups"; +import OrderFulfillmentGroup from "components/OrderFulfillmentGroup"; import PageLoading from "components/PageLoading"; import withCart from "containers/cart/withCart"; import withOrder from "containers/order/withOrder"; @@ -85,7 +85,12 @@ class CheckoutComplete extends Component { loadMoreCartItems: PropTypes.func, onChangeCartItemsQuantity: PropTypes.func, onRemoveCartItems: PropTypes.func, - order: PropTypes.object, + order: PropTypes.shape({ + email: PropTypes.string.isRequired, + fulfillmentGroups: PropTypes.arrayOf(PropTypes.object).isRequired, + payments: PropTypes.arrayOf(PropTypes.object), + referenceId: PropTypes.string.isRequired + }), shop: PropTypes.shape({ name: PropTypes.string.isRequired, description: PropTypes.string @@ -111,7 +116,9 @@ class CheckoutComplete extends Component { return (
- + {order.fulfillmentGroups.map((fulfillmentGroup, index) => ( + + ))}
); @@ -144,14 +151,12 @@ class CheckoutComplete extends Component {
- - {"Thank you for your order"} - + Thank you for your order - {"Your order ID is:"} {order && order.referenceId} + {"Your order ID is:"} {order.referenceId} - {"We've sent a confirmation email to:"} {order && order.email} + {"We've sent a confirmation email to:"} {order.email}
{this.renderFulfillmentGroups()}
From 93572f4f368874411191931fa51fafaa9cd27763 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Mon, 7 Jan 2019 11:54:31 -0600 Subject: [PATCH 02/15] feat: new components to support multiple payment methods at checkout Temporarily creating new components here. Will move to RDS --- src/components/AddressChoice.js | 125 ++++++++ .../CheckoutActions/CheckoutActions.js | 94 ++++--- src/components/PaymentsCheckoutAction.js | 266 ++++++++++++++++++ src/components/StripePaymentCheckoutAction.js | 225 --------------- src/components/StripePaymentInput.js | 116 ++++++++ src/containers/cart/fragments.gql | 17 ++ src/custom/componentsContext.js | 10 +- src/lib/stores/CartStore.js | 12 +- src/lib/utils/cartUtils.js | 15 - 9 files changed, 595 insertions(+), 285 deletions(-) create mode 100644 src/components/AddressChoice.js create mode 100644 src/components/PaymentsCheckoutAction.js delete mode 100644 src/components/StripePaymentCheckoutAction.js create mode 100644 src/components/StripePaymentInput.js delete mode 100644 src/lib/utils/cartUtils.js diff --git a/src/components/AddressChoice.js b/src/components/AddressChoice.js new file mode 100644 index 0000000000..b46b186cb5 --- /dev/null +++ b/src/components/AddressChoice.js @@ -0,0 +1,125 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { withComponents } from "@reactioncommerce/components-context"; +import { CustomPropTypes } from "@reactioncommerce/components/utils"; + +class AddressChoice extends Component { + static propTypes = { + addresses: PropTypes.arrayOf(PropTypes.shape({ + address1: PropTypes.string.isRequired + })), + /** + * You can provide a `className` prop that will be applied to the outermost DOM element + * rendered by this component. We do not recommend using this for styling purposes, but + * it can be useful as a selector in some situations. + */ + className: PropTypes.string, + /** + * If you've set up a components context using + * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) + * (recommended), then this prop will come from there automatically. If you have not + * set up a components context or you want to override one of the components in a + * single spot, you can pass in the components prop directly. + */ + components: PropTypes.shape({ + /** + * Pass either the Reaction AddressForm component or your own component that + * accepts compatible props. + */ + AddressForm: CustomPropTypes.component.isRequired, + /** + * A reaction SelectableList component or compatible component. + */ + SelectableList: CustomPropTypes.component.isRequired + }), + /** + * Disable editing? + */ + isReadOnly: PropTypes.bool, + /** + * Called with an address whenever the selected or entered + * address changes. If they selected one, it will be the + * complete address that was passed in `addresses`. If they're + * entering one, it will be whatever they have entered so far + * and may be partial. + */ + onChange: PropTypes.func + }; + + static defaultProps = { + isReadOnly: false, + onChange() {} + }; + + constructor(props) { + super(props); + + let selectedOption = "OTHER"; + if (Array.isArray(props.addresses) && props.addresses.length > 0) { + selectedOption = "0"; + } + + this.state = { selectedOption }; + } + + handleChangeAddress = (address) => { + this.props.onChange(address); + } + + handleChangeSelection = (selectedOption) => { + const { addresses } = this.props; + + this.setState({ selectedOption }); + + if (selectedOption !== "OTHER" && Array.isArray(addresses)) { + this.props.onChange(addresses[Number(selectedOption)]); + } + } + + renderSelectList() { + const { + addresses, + components: { SelectableList }, + isReadOnly + } = this.props; + const { selectedOption } = this.state; + + if (!Array.isArray(addresses) || addresses.length === 0) return null; + + const listOptions = addresses.map((address, index) => ({ + id: String(index), + label: address.address1, + value: String(index) + })); + + listOptions.push({ + id: "OTHER", + label: "Use a different billing address", + value: "OTHER" + }); + + return ( + + ); + } + + render() { + const { className, components: { AddressForm }, isReadOnly } = this.props; + const { selectedOption } = this.state; + + return ( +
+ {this.renderSelectList()} + {selectedOption === "OTHER" && } +
+ ); + } +} + +export default withComponents(AddressChoice); diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 389cab9b19..22853e0da9 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -5,7 +5,7 @@ import isEqual from "lodash.isequal"; import Actions from "@reactioncommerce/components/CheckoutActions/v1"; import ShippingAddressCheckoutAction from "@reactioncommerce/components/ShippingAddressCheckoutAction/v1"; import FulfillmentOptionsCheckoutAction from "@reactioncommerce/components/FulfillmentOptionsCheckoutAction/v1"; -import StripePaymentCheckoutAction from "components/StripePaymentCheckoutAction"; +import PaymentsCheckoutAction from "components/PaymentsCheckoutAction"; import FinalReviewCheckoutAction from "@reactioncommerce/components/FinalReviewCheckoutAction/v1"; import withCart from "containers/cart/withCart"; import withAddressValidation from "containers/address/withAddressValidation"; @@ -17,7 +17,7 @@ import TRACKING from "lib/tracking/constants"; import trackCheckout from "lib/tracking/trackCheckout"; import trackOrder from "lib/tracking/trackOrder"; import trackCheckoutStep from "lib/tracking/trackCheckoutStep"; -import { isShippingAddressSet } from "lib/utils/cartUtils"; +import StripePaymentInput from "../StripePaymentInput"; import { placeOrder } from "../../containers/order/mutations.gql"; const { @@ -42,10 +42,7 @@ export default class CheckoutActions extends Component { email: PropTypes.string, items: PropTypes.array }), - cartStore: PropTypes.shape({ - checkoutPaymentInputData: PropTypes.object, - setCheckoutPaymentInputData: PropTypes.func - }), + cartStore: PropTypes.object, checkoutMutations: PropTypes.shape({ onSetFulfillmentOption: PropTypes.func.isRequired, onSetShippingAddress: PropTypes.func.isRequired @@ -67,15 +64,17 @@ export default class CheckoutActions extends Component { componentDidMount() { this._isMounted = true; const { cart } = this.props; + // Track start of checkout process this.trackCheckoutStarted({ cart, action: CHECKOUT_STARTED }); - const { checkout: { fulfillmentGroups } } = this.props.cart; - const hasShippingAddress = isShippingAddressSet(fulfillmentGroups); + const { checkout: { fulfillmentGroups } } = cart; + const [fulfillmentGroup] = fulfillmentGroups; + // Track the first step, "Enter a shipping address" when the page renders, // as it will be expanded by default, only record this event when the // shipping address has not yet been set. - if (!hasShippingAddress) { + if (!fulfillmentGroup.shippingAddress) { this.trackAction(this.buildData({ action: CHECKOUT_STEP_VIEWED, step: 1 })); } } @@ -118,8 +117,8 @@ export default class CheckoutActions extends Component { } get paymentMethod() { - const { checkoutPaymentInputData } = this.props.cartStore; - return checkoutPaymentInputData ? checkoutPaymentInputData.payment.method : null; + const [firstPayment] = this.props.cartStore.checkoutPayments; + return firstPayment ? firstPayment.payment.method : null; } setShippingAddress = async (address) => { @@ -174,8 +173,8 @@ export default class CheckoutActions extends Component { } }; - setPaymentMethod = (paymentInputData) => { - this.props.cartStore.setCheckoutPaymentInputData(paymentInputData); + handlePaymentSubmit = (paymentInput) => { + this.props.cartStore.addCheckoutPayment(paymentInput); this.setState({ hasPaymentError: false, @@ -191,6 +190,10 @@ export default class CheckoutActions extends Component { this.trackAction(this.buildData({ action: CHECKOUT_STEP_VIEWED, step: 4 })); }; + handlePaymentsReset = () => { + this.props.cartStore.resetCheckoutPayments(); + } + buildOrder = async () => { const { cart, cartStore, orderEmailAddress } = this.props; const cartId = cartStore.hasAccountCart ? cartStore.accountCartId : cartStore.anonymousCartId; @@ -219,7 +222,7 @@ export default class CheckoutActions extends Component { const order = { cartId, - currencyCode: cart.currencyCode, + currencyCode: checkout.summary.total.currency.code, email: orderEmailAddress, fulfillmentGroups, shopId: cart.shop._id @@ -230,16 +233,21 @@ export default class CheckoutActions extends Component { placeOrder = async (order) => { const { cartStore, client: apolloClient } = this.props; - const { payment } = cartStore.checkoutPaymentInputData || {}; + + // Payments can have `null` amount to mean "remaining". + const orderTotal = order.fulfillmentGroups.reduce((sum, group) => sum + group.totalPrice, 0); + const payments = cartStore.checkoutPayments.map(({ payment }) => ({ + ...payment, + amount: payment.amount || orderTotal + })); try { - const amount = order.fulfillmentGroups.reduce((sum, group) => sum + group.totalPrice, 0); const { data } = await apolloClient.mutate({ mutation: placeOrder, variables: { input: { order, - payments: [{ ...payment, amount }] + payments } } }); @@ -250,7 +258,7 @@ export default class CheckoutActions extends Component { cartStore.clearAnonymousCartCredentials(); // Also destroy the collected and cached payment input - cartStore.setCheckoutPaymentInputData(null); + cartStore.resetCheckoutPayments(); const { placeOrder: { orders, token } } = data; @@ -293,20 +301,7 @@ export default class CheckoutActions extends Component { const { checkout: { fulfillmentGroups, summary }, items } = cart; const { actionAlerts, hasPaymentError } = this.state; - const shippingAddressSet = isShippingAddressSet(fulfillmentGroups); - const fulfillmentGroup = fulfillmentGroups[0]; - - let shippingAddress = { data: { shippingAddress: null } }; - - if (shippingAddressSet) { - shippingAddress = { - data: { - shippingAddress: fulfillmentGroup.data.shippingAddress - } - }; - } - - const paymentData = cartStore.checkoutPaymentInputData; + const [fulfillmentGroup] = fulfillmentGroups; // Order summary const { fulfillmentTotal, itemTotal, taxTotal, total } = summary; @@ -318,19 +313,27 @@ export default class CheckoutActions extends Component { items }; + const addresses = fulfillmentGroups.reduce((list, group) => { + if (group.shippingAddress) list.push(group.shippingAddress); + return list; + }, []); + + const payments = cartStore.checkoutPayments.slice(); + const remainingDue = payments.reduce((val, { payment }) => val - (payment.amount || val), total.amount); + const actions = [ { id: "1", activeLabel: "Enter a shipping address", completeLabel: "Shipping address", incompleteLabel: "Shipping address", - status: shippingAddressSet ? "complete" : "incomplete", + status: fulfillmentGroup.type !== "shipping" || fulfillmentGroup.shippingAddress ? "complete" : "incomplete", component: ShippingAddressCheckoutAction, onSubmit: this.setShippingAddress, props: { addressValidationResults, alert: actionAlerts["1"], - fulfillmentGroup: shippingAddress, + fulfillmentGroup, onAddressValidation: addressValidation } }, @@ -352,12 +355,27 @@ export default class CheckoutActions extends Component { activeLabel: "Enter payment information", completeLabel: "Payment information", incompleteLabel: "Payment information", - status: paymentData && !hasPaymentError ? "complete" : "incomplete", - component: StripePaymentCheckoutAction, - onSubmit: this.setPaymentMethod, + status: remainingDue === 0 && !hasPaymentError ? "complete" : "incomplete", + component: PaymentsCheckoutAction, + onSubmit: this.handlePaymentSubmit, props: { + addresses, alert: actionAlerts["3"], - paymentData + onReset: this.handlePaymentsReset, + payments, + paymentMethods: [ + { + displayName: "Credit Card", + InputComponent: StripePaymentInput, + name: "stripe_card", + shouldCollectBillingAddress: true + }, + { + displayName: "IOU", + name: "iou_example", + shouldCollectBillingAddress: true + } + ] } }, { diff --git a/src/components/PaymentsCheckoutAction.js b/src/components/PaymentsCheckoutAction.js new file mode 100644 index 0000000000..3966a322be --- /dev/null +++ b/src/components/PaymentsCheckoutAction.js @@ -0,0 +1,266 @@ +import React, { Component, Fragment } from "react"; +import PropTypes from "prop-types"; +import { withComponents } from "@reactioncommerce/components-context"; +import styled from "styled-components"; +import { addTypographyStyles, CustomPropTypes } from "@reactioncommerce/components/utils"; + +const Title = styled.h3` + ${addTypographyStyles("PaymentsCheckoutAction", "subheadingTextBold")} +`; + +const ActionCompleteDiv = styled.div` + ${addTypographyStyles("PaymentsCheckoutActionComplete", "bodyText")}; +`; + +class PaymentsCheckoutAction extends Component { + static renderComplete({ payments }) { + const text = payments.map(({ displayName }) => displayName).join(", "); + return ( + {text} + ); + } + + static propTypes = { + addresses: PropTypes.arrayOf(PropTypes.shape({ + address1: PropTypes.string.isRequired + })), + /** + * Alert object provides alert into to InlineAlert. + */ + alert: CustomPropTypes.alert, + /** + * You can provide a `className` prop that will be applied to the outermost DOM element + * rendered by this component. We do not recommend using this for styling purposes, but + * it can be useful as a selector in some situations. + */ + className: PropTypes.string, + /** + * If you've set up a components context using + * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) + * (recommended), then this prop will come from there automatically. If you have not + * set up a components context or you want to override one of the components in a + * single spot, you can pass in the components prop directly. + */ + components: PropTypes.shape({ + /** + * Pass either the Reaction AddressChoice component or your own component that + * accepts compatible props. + */ + AddressChoice: CustomPropTypes.component.isRequired, + /** + * Pass either the Reaction InlineAlert component or your own component that + * accepts compatible props. + */ + InlineAlert: CustomPropTypes.component.isRequired, + /** + * A reaction SelectableList component or compatible component. + */ + SelectableList: CustomPropTypes.component.isRequired + }), + /** + * Is the payment input being saved? + */ + isSaving: PropTypes.bool, + /** + * Label of workflow step + */ + label: PropTypes.string.isRequired, + /** + * When this action's input data switches between being + * ready for saving and not ready for saving, this will + * be called with `true` (ready) or `false` + */ + onReadyForSaveChange: PropTypes.func, + /** + * When called, the parent should clear all previously submitted + * payments from state + */ + onReset: PropTypes.func, + /** + * Called with an object value when this component's `submit` + * method is called. The object has a `payment` property, where + * `payment` is the Payment that should be passed to the `placeOrder` + * mutation, and a `displayName` property. + */ + onSubmit: PropTypes.func, + /** + * List of all payment methods available for this shop / checkout + */ + paymentMethods: PropTypes.arrayOf(PropTypes.shape({ + displayName: PropTypes.string.isRequired, + InputComponent: CustomPropTypes.component, + name: PropTypes.string.isRequired, + shouldCollectBillingAddress: PropTypes.bool.isRequired + })), + /** + * Pass in payment objects previously passed to onSubmit + */ + payments: PropTypes.arrayOf(PropTypes.object), + /** + * Checkout process step number + */ + stepNumber: PropTypes.number.isRequired + }; + + static defaultProps = { + onReadyForSaveChange() {}, + onReset() {}, + onSubmit() {} + }; + + constructor(props) { + super(props); + + const { addresses, paymentMethods } = props; + + let selectedPaymentMethodName = null; + if (Array.isArray(paymentMethods)) { + const [method] = paymentMethods; + if (method) { + selectedPaymentMethodName = method.name; + } + } + + this.state = { + billingAddress: addresses && addresses[0] ? addresses[0] : null, + inputIsComplete: false, + selectedPaymentMethodName + }; + } + + componentDidMount() { + this.checkIfReadyForSaveChange(); + this.props.onReset(); + } + + _inputComponent = null; + + submit = async () => { + if (this._inputComponent) { + this._inputComponent.submit(); + } else { + this.handleInputComponentSubmit(); + } + } + + handleInputComponentSubmit = async ({ amount = null, data, displayName } = {}) => { + const { onSubmit, paymentMethods } = this.props; + const { billingAddress, selectedPaymentMethodName } = this.state; + + const selectedPaymentMethod = paymentMethods.find((method) => method.name === selectedPaymentMethodName); + + await onSubmit({ + displayName: displayName || selectedPaymentMethod.displayName, + payment: { + amount, + billingAddress, + data, + method: selectedPaymentMethodName + } + }); + } + + checkIfReadyForSaveChange() { + const { onReadyForSaveChange, paymentMethods } = this.props; + const { billingAddress, inputIsComplete, selectedPaymentMethodName } = this.state; + + const isFilled = billingAddress && + Object.keys(billingAddress).every((key) => (["address2", "company"].indexOf(key) > -1 ? true : billingAddress[key] !== null)); + + const selectedPaymentMethod = paymentMethods.find((method) => method.name === selectedPaymentMethodName); + const isInputReady = !selectedPaymentMethod || !selectedPaymentMethod.InputComponent || inputIsComplete; + + onReadyForSaveChange(!!(isInputReady && isFilled)); + } + + handleAddressChange = (billingAddress = null) => { + this.setState({ billingAddress }, () => { + this.checkIfReadyForSaveChange(); + }); + }; + + handleInputReadyForSaveChange = (inputIsComplete) => { + this.setState({ inputIsComplete }, () => { + this.checkIfReadyForSaveChange(); + }); + } + + handleSelectedPaymentMethodChange = (selectedPaymentMethodName) => { + this.setState({ selectedPaymentMethodName }, () => { + this.checkIfReadyForSaveChange(); + }); + }; + + renderBillingAddressForm() { + const { addresses, components: { AddressChoice }, isSaving } = this.props; + + return ( + + Billing Address + + + ); + } + + renderPaymentMethodList() { + const { + components: { SelectableList }, + isSaving, + paymentMethods + } = this.props; + + if (paymentMethods.length < 2) return null; + + const { selectedPaymentMethodName } = this.state; + const options = paymentMethods.map((method) => ({ + id: method.name, + label: method.displayName, + value: method.name + })); + + return ( + + ); + } + + render() { + const { + alert, + className, + components: { InlineAlert }, + isSaving, + label, + paymentMethods, + stepNumber + } = this.props; + + const { selectedPaymentMethodName } = this.state; + const selectedPaymentMethod = paymentMethods.find((method) => method.name === selectedPaymentMethodName); + + return ( +
+ + {stepNumber}. {label} + + {alert ? : ""} + {this.renderPaymentMethodList()} + {!!selectedPaymentMethod && !!selectedPaymentMethod.InputComponent && + { this._inputComponent = instance; }} + />} + {!!selectedPaymentMethod && !!selectedPaymentMethod.shouldCollectBillingAddress && this.renderBillingAddressForm()} +
+ ); + } +} + +export default withComponents(PaymentsCheckoutAction); diff --git a/src/components/StripePaymentCheckoutAction.js b/src/components/StripePaymentCheckoutAction.js deleted file mode 100644 index 5426786188..0000000000 --- a/src/components/StripePaymentCheckoutAction.js +++ /dev/null @@ -1,225 +0,0 @@ -import React, { Component, Fragment } from "react"; -import PropTypes from "prop-types"; -import { withComponents } from "@reactioncommerce/components-context"; -import Fade from "@material-ui/core/Fade"; -import styled from "styled-components"; -import { addTypographyStyles, CustomPropTypes } from "@reactioncommerce/components/utils"; - -const Title = styled.h3` - ${addTypographyStyles("StripePaymentCheckoutAction", "subheadingTextBold")} -`; - -const SecureCaption = styled.div` - ${addTypographyStyles("StripePaymentCheckoutAction", "captionText")} -`; - -const IconLockSpan = styled.span` - display: inline-block; - height: 20px; - width: 20px; -`; - -const Span = styled.span` - vertical-align: super; -`; - -const billingAddressOptions = [{ - id: "1", - label: "Same as shipping address", - value: "same_as_shipping" -}, -{ - id: "2", - label: "Use a different billing address", - value: "use_different_billing_address" -}]; - -class StripePaymentCheckoutAction extends Component { - static renderComplete({ paymentData }) { - return ( -
- {!!paymentData && paymentData.displayName} -
- ); - } - - static propTypes = { - /** - * Alert object provides alert into to InlineAlert. - */ - alert: CustomPropTypes.alert, - /** - * If you've set up a components context using - * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) - * (recommended), then this prop will come from there automatically. If you have not - * set up a components context or you want to override one of the components in a - * single spot, you can pass in the components prop directly. - */ - components: PropTypes.shape({ - /** - * Pass either the Reaction AddressForm component or your own component that - * accepts compatible props. - */ - AddressForm: CustomPropTypes.component.isRequired, - /** - * Secured lock icon - */ - iconLock: PropTypes.node, - /** - * A reaction SelectableList component or compatible component. - */ - SelectableList: CustomPropTypes.component.isRequired, - /** - * Pass either the Reaction StripeForm component or your own component that - * accepts compatible props. - */ - StripeForm: CustomPropTypes.component.isRequired, - /** - * Pass either the Reaction InlineAlert component or your own component that - * accepts compatible props. - */ - InlineAlert: CustomPropTypes.component.isRequired - }), - /** - * Is the payment input being saved? - */ - isSaving: PropTypes.bool, - /** - * Label of workflow step - */ - label: PropTypes.string.isRequired, - /** - * When this action's input data switches between being - * ready for saving and not ready for saving, this will - * be called with `true` (ready) or `false` - */ - onReadyForSaveChange: PropTypes.func, - /** - * Called with an object value when this component's `submit` - * method is called. The object has `payment` and `displayName` - * properties, where `payment` is the Payment that should be - * passed to the `placeOrder` mutation. - */ - onSubmit: PropTypes.func, - /** - * Checkout process step number - */ - stepNumber: PropTypes.number.isRequired - }; - - static defaultProps = { - onReadyForSaveChange() { } - }; - - state = { - billingAddressOptionValue: "same_as_shipping" - }; - - componentDidMount() { - const { onReadyForSaveChange } = this.props; - onReadyForSaveChange(false); - } - - _addressForm = null; - - submit = async () => { - const { billingAddressOptionValue } = this.state; - - // If user chooses to use billing address to be the same as shipping, then - // don't submit the billing address form - if (billingAddressOptionValue === "same_as_shipping") { - await this.handleSubmit(); - } else { - await this._addressForm.submit(); - } - } - - handleSubmit = async (value) => { - const { onSubmit } = this.props; - const { token } = await this._stripe.createToken(); - - await onSubmit({ - displayName: `${token.card.brand} ending in ${token.card.last4}`, - payment: { - billingAddress: value, - data: { - stripeTokenId: token.id - }, - method: "stripe_card" - } - }); - } - - handleChange = (values) => { - const { onReadyForSaveChange } = this.props; - const isFilled = Object.keys(values).every((key) => (key === "address2" ? true : values[key] !== null)); - - onReadyForSaveChange(isFilled); - }; - - handleUseNewBillingAddress = (billingAddressOptionValue) => { - this.setState({ billingAddressOptionValue }); - } - - renderBillingAddressForm = () => { - const { components: { AddressForm } } = this.props; - const { billingAddressOptionValue } = this.state; - - // Only render billing address when user chooses to use - // a different billing address - if (billingAddressOptionValue === "same_as_shipping") { - return null; - } - - - return ( - - { - this._addressForm = formEl; - }} - onSubmit={this.handleSubmit} - onChange={this.handleChange} - /> - - ); - } - - render() { - const { - alert, - components: { iconLock, InlineAlert, SelectableList, StripeForm }, - label, - onReadyForSaveChange, - stepNumber - } = this.props; - - const { billingAddressOptionValue } = this.state; - - return ( - - - {stepNumber}. {label} - - {alert ? : ""} - { this._stripe = stripe; }} - /> - - {iconLock} Your Information is private and secure. - - Billing Address - - {this.renderBillingAddressForm()} - - ); - } -} - -export default withComponents(StripePaymentCheckoutAction); diff --git a/src/components/StripePaymentInput.js b/src/components/StripePaymentInput.js new file mode 100644 index 0000000000..53c3693c13 --- /dev/null +++ b/src/components/StripePaymentInput.js @@ -0,0 +1,116 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { withComponents } from "@reactioncommerce/components-context"; +import styled from "styled-components"; +import { addTypographyStyles, CustomPropTypes } from "@reactioncommerce/components/utils"; + +const SecureCaption = styled.div` + ${addTypographyStyles("StripePaymentInput", "captionText")} +`; + +const IconLockSpan = styled.span` + display: inline-block; + height: 20px; + width: 20px; +`; + +const Span = styled.span` + vertical-align: super; +`; + +class StripePaymentInput extends Component { + static renderComplete({ displayName } = {}) { + return ( +
+ {displayName} +
+ ); + } + + static propTypes = { + /** + * You can provide a `className` prop that will be applied to the outermost DOM element + * rendered by this component. We do not recommend using this for styling purposes, but + * it can be useful as a selector in some situations. + */ + className: PropTypes.string, + /** + * If you've set up a components context using + * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) + * (recommended), then this prop will come from there automatically. If you have not + * set up a components context or you want to override one of the components in a + * single spot, you can pass in the components prop directly. + */ + components: PropTypes.shape({ + /** + * Secured lock icon + */ + iconLock: PropTypes.node, + /** + * Pass either the Reaction StripeForm component or your own component that + * accepts compatible props. + */ + StripeForm: CustomPropTypes.component.isRequired + }), + /** + * Is the payment input being saved? + */ + isSaving: PropTypes.bool, + /** + * When this action's input data switches between being + * ready for saving and not ready for saving, this will + * be called with `true` (ready) or `false` + */ + onReadyForSaveChange: PropTypes.func, + /** + * Called with an object value when this component's `submit` + * method is called. The object has `payment` and `displayName` + * properties, where `payment` is the Payment that should be + * passed to the `placeOrder` mutation. + */ + onSubmit: PropTypes.func + }; + + static defaultProps = { + onReadyForSaveChange() { } + }; + + componentDidMount() { + const { onReadyForSaveChange } = this.props; + onReadyForSaveChange(false); + } + + submit = async () => { + const { onSubmit } = this.props; + const { token } = await this._stripe.createToken(); + + await onSubmit({ + displayName: `${token.card.brand} ending in ${token.card.last4}`, + data: { + stripeTokenId: token.id + } + }); + } + + render() { + const { + className, + components: { iconLock, StripeForm }, + onReadyForSaveChange + } = this.props; + + return ( +
+ { this._stripe = stripe; }} + /> + + {iconLock} Your Information is private and secure. + +
+ ); + } +} + +export default withComponents(StripePaymentInput); diff --git a/src/containers/cart/fragments.gql b/src/containers/cart/fragments.gql index 538431050e..2ce2eba0ad 100644 --- a/src/containers/cart/fragments.gql +++ b/src/containers/cart/fragments.gql @@ -66,6 +66,20 @@ fragment CartCommon on Cart { shop { _id } + shippingAddress { + address1 + address2 + city + company + country + fullName + isBillingDefault + isCommercial + isShippingDefault + phone + postal + region + } } summary { fulfillmentTotal { @@ -81,6 +95,9 @@ fragment CartCommon on Cart { } total { amount + currency { + code + } displayAmount } } diff --git a/src/custom/componentsContext.js b/src/custom/componentsContext.js index a3c492c742..f5519e33c0 100644 --- a/src/custom/componentsContext.js +++ b/src/custom/componentsContext.js @@ -12,16 +12,18 @@ * with the `withComponents` higher-order component. */ -import iconClear from "@reactioncommerce/components/svg/iconClear"; -import iconError from "@reactioncommerce/components/svg/iconError"; -import iconValid from "@reactioncommerce/components/svg/iconValid"; import iconAmericanExpress from "@reactioncommerce/components/svg/iconAmericanExpress"; +import iconClear from "@reactioncommerce/components/svg/iconClear"; import iconDiscover from "@reactioncommerce/components/svg/iconDiscover"; +import iconError from "@reactioncommerce/components/svg/iconError"; +import iconLock from "@reactioncommerce/components/svg/iconLock"; import iconMastercard from "@reactioncommerce/components/svg/iconMastercard"; +import iconValid from "@reactioncommerce/components/svg/iconValid"; import iconVisa from "@reactioncommerce/components/svg/iconVisa"; import spinner from "@reactioncommerce/components/svg/spinner"; import Address from "@reactioncommerce/components/Address/v1"; import AddressCapture from "@reactioncommerce/components/AddressCapture/v1"; +import AddressChoice from "components/AddressChoice"; import AddressForm from "@reactioncommerce/components/AddressForm/v1"; import AddressReview from "@reactioncommerce/components/AddressReview/v1"; import BadgeOverlay from "@reactioncommerce/components/BadgeOverlay/v1"; @@ -62,6 +64,7 @@ const AddressFormWithLocales = withLocales(AddressForm); export default { Address, AddressCapture, + AddressChoice, AddressForm: AddressFormWithLocales, AddressReview, BadgeOverlay, @@ -85,6 +88,7 @@ export default { iconClear, iconDiscover, iconError, + iconLock, iconMastercard, iconValid, iconVisa, diff --git a/src/lib/stores/CartStore.js b/src/lib/stores/CartStore.js index b0bb868217..c051f9938f 100644 --- a/src/lib/stores/CartStore.js +++ b/src/lib/stores/CartStore.js @@ -40,9 +40,9 @@ class CartStore { /** * Payment data from the payment action during checkout * - * @type Object + * @type Object[] */ - @observable checkoutPaymentInputData = null; + @observable checkoutPayments = []; /** * @name setAnonymousCartCredentials @@ -124,8 +124,12 @@ class CartStore { this.accountCartId = value; } - @action setCheckoutPaymentInputData(value) { - this.checkoutPaymentInputData = value; + @action addCheckoutPayment(value) { + this.checkoutPayments.push(value); + } + + @action resetCheckoutPayments() { + this.checkoutPayments = []; } } diff --git a/src/lib/utils/cartUtils.js b/src/lib/utils/cartUtils.js deleted file mode 100644 index 3eae5a6d70..0000000000 --- a/src/lib/utils/cartUtils.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Determines if there is any fulfillment group without a shipping address - * - * @param {Array} fulfillmentGroups - An array of available fulfillment groups - * @returns {Boolean} - true if at least one fulfillment group does not have - * a shipping address set, false otherwise. - */ -export function isShippingAddressSet(fulfillmentGroups) { - const groupWithoutAddress = fulfillmentGroups.find((group) => { - const shippingGroup = group.type === "shipping"; - return shippingGroup && group.data.shippingAddress; - }); - - return groupWithoutAddress; -} From 443d07501631e5b3b048e6b67598a5f60aa6d249 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Mon, 7 Jan 2019 13:45:29 -0600 Subject: [PATCH 03/15] feat: add Example IOU payment form --- .../CheckoutActions/CheckoutActions.js | 16 +-- src/components/ExampleIOUPaymentForm.js | 123 ++++++++++++++++++ src/components/StripePaymentInput.js | 13 +- src/custom/paymentMethods.js | 19 +++ 4 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 src/components/ExampleIOUPaymentForm.js create mode 100644 src/custom/paymentMethods.js diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 22853e0da9..0bb8248e2c 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -17,8 +17,8 @@ import TRACKING from "lib/tracking/constants"; import trackCheckout from "lib/tracking/trackCheckout"; import trackOrder from "lib/tracking/trackOrder"; import trackCheckoutStep from "lib/tracking/trackCheckoutStep"; -import StripePaymentInput from "../StripePaymentInput"; import { placeOrder } from "../../containers/order/mutations.gql"; +import paymentMethods from "../../custom/paymentMethods"; const { CHECKOUT_STARTED, @@ -363,19 +363,7 @@ export default class CheckoutActions extends Component { alert: actionAlerts["3"], onReset: this.handlePaymentsReset, payments, - paymentMethods: [ - { - displayName: "Credit Card", - InputComponent: StripePaymentInput, - name: "stripe_card", - shouldCollectBillingAddress: true - }, - { - displayName: "IOU", - name: "iou_example", - shouldCollectBillingAddress: true - } - ] + paymentMethods } }, { diff --git a/src/components/ExampleIOUPaymentForm.js b/src/components/ExampleIOUPaymentForm.js new file mode 100644 index 0000000000..64d7fa13bc --- /dev/null +++ b/src/components/ExampleIOUPaymentForm.js @@ -0,0 +1,123 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Form } from "reacto-form"; +import { uniqueId } from "lodash"; +import { withComponents } from "@reactioncommerce/components-context"; +import { CustomPropTypes } from "@reactioncommerce/components/utils"; + +class ExampleIOUPaymentForm extends Component { + static propTypes = { + /** + * You can provide a `className` prop that will be applied to the outermost DOM element + * rendered by this component. We do not recommend using this for styling purposes, but + * it can be useful as a selector in some situations. + */ + className: PropTypes.string, + /** + * If you've set up a components context using + * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) + * (recommended), then this prop will come from there automatically. If you have not + * set up a components context or you want to override one of the components in a + * single spot, you can pass in the components prop directly. + */ + components: PropTypes.shape({ + /** + * Pass either the Reaction ErrorsBlock component or your own component that + * accepts compatible props. + */ + ErrorsBlock: CustomPropTypes.component.isRequired, + /** + * Pass either the Reaction Field component or your own component that + * accepts compatible props. + */ + Field: CustomPropTypes.component.isRequired, + /** + * Pass either the Reaction TextInput component or your own component that + * accepts compatible props. + */ + TextInput: CustomPropTypes.component.isRequired + }), + /** + * Is the payment input being saved? + */ + isSaving: PropTypes.bool, + /** + * When this action's input data switches between being + * ready for saving and not ready for saving, this will + * be called with `true` (ready) or `false` + */ + onReadyForSaveChange: PropTypes.func, + /** + * Called with an object value when this component's `submit` + * method is called. The object may have `data`, `displayName`, + * and `amount` properties. + */ + onSubmit: PropTypes.func + } + + static defaultProps = { + onReadyForSaveChange() { } + }; + + componentDidMount() { + const { onReadyForSaveChange } = this.props; + onReadyForSaveChange(false); + } + + uniqueInstanceIdentifier = uniqueId("ExampleIOUPaymentForm"); + + submit() { + if (this.form) this.form.submit(); + } + + handleChange = ({ fullName }) => { + const { onReadyForSaveChange } = this.props; + onReadyForSaveChange(!!fullName); + } + + handleSubmit = ({ amount, fullName }) => { + const { onSubmit } = this.props; + + return onSubmit({ + amount: amount ? parseFloat(amount) : null, + data: { fullName }, + displayName: `IOU from ${fullName}` + }); + } + + render() { + const { + className, + components: { + ErrorsBlock, + Field, + TextInput + }, + isSaving + } = this.props; + + const fullNameInputId = `fullName_${this.uniqueInstanceIdentifier}`; + const amountInputId = `amount_${this.uniqueInstanceIdentifier}`; + + return ( +
{ this.form = formRef; }} + > + + + + + + + + +
+ ); + } +} + +export default withComponents(ExampleIOUPaymentForm); diff --git a/src/components/StripePaymentInput.js b/src/components/StripePaymentInput.js index 53c3693c13..9b7410886b 100644 --- a/src/components/StripePaymentInput.js +++ b/src/components/StripePaymentInput.js @@ -19,14 +19,6 @@ const Span = styled.span` `; class StripePaymentInput extends Component { - static renderComplete({ displayName } = {}) { - return ( -
- {displayName} -
- ); - } - static propTypes = { /** * You can provide a `className` prop that will be applied to the outermost DOM element @@ -64,9 +56,8 @@ class StripePaymentInput extends Component { onReadyForSaveChange: PropTypes.func, /** * Called with an object value when this component's `submit` - * method is called. The object has `payment` and `displayName` - * properties, where `payment` is the Payment that should be - * passed to the `placeOrder` mutation. + * method is called. The object may have `data`, `displayName`, + * and `amount` properties. */ onSubmit: PropTypes.func }; diff --git a/src/custom/paymentMethods.js b/src/custom/paymentMethods.js new file mode 100644 index 0000000000..b904139e34 --- /dev/null +++ b/src/custom/paymentMethods.js @@ -0,0 +1,19 @@ +import ExampleIOUPaymentForm from "../components/ExampleIOUPaymentForm"; +import StripePaymentInput from "../components/StripePaymentInput"; + +const paymentMethods = [ + { + displayName: "Credit Card", + InputComponent: StripePaymentInput, + name: "stripe_card", + shouldCollectBillingAddress: true + }, + { + displayName: "IOU", + InputComponent: ExampleIOUPaymentForm, + name: "iou_example", + shouldCollectBillingAddress: true + } +]; + +export default paymentMethods; From ccf9b45a1679e090e0ea18ed187cc7196f8738bd Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Mon, 7 Jan 2019 14:08:01 -0600 Subject: [PATCH 04/15] feat: show partial payments on payment step --- .../CheckoutActions/CheckoutActions.js | 18 +++++----- src/components/PaymentsCheckoutAction.js | 36 ++++++++++++++++--- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 0bb8248e2c..5456a90680 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -235,11 +235,12 @@ export default class CheckoutActions extends Component { const { cartStore, client: apolloClient } = this.props; // Payments can have `null` amount to mean "remaining". - const orderTotal = order.fulfillmentGroups.reduce((sum, group) => sum + group.totalPrice, 0); - const payments = cartStore.checkoutPayments.map(({ payment }) => ({ - ...payment, - amount: payment.amount || orderTotal - })); + let remainingAmountDue = order.fulfillmentGroups.reduce((sum, group) => sum + group.totalPrice, 0); + const payments = cartStore.checkoutPayments.map(({ payment }) => { + const amount = payment.amount ? Math.min(payment.amount, remainingAmountDue) : remainingAmountDue; + remainingAmountDue -= amount; + return { ...payment, amount }; + }); try { const { data } = await apolloClient.mutate({ @@ -319,7 +320,7 @@ export default class CheckoutActions extends Component { }, []); const payments = cartStore.checkoutPayments.slice(); - const remainingDue = payments.reduce((val, { payment }) => val - (payment.amount || val), total.amount); + const remainingAmountDue = payments.reduce((val, { payment }) => val - (payment.amount || val), total.amount); const actions = [ { @@ -355,7 +356,7 @@ export default class CheckoutActions extends Component { activeLabel: "Enter payment information", completeLabel: "Payment information", incompleteLabel: "Payment information", - status: remainingDue === 0 && !hasPaymentError ? "complete" : "incomplete", + status: remainingAmountDue === 0 && !hasPaymentError ? "complete" : "incomplete", component: PaymentsCheckoutAction, onSubmit: this.handlePaymentSubmit, props: { @@ -363,7 +364,8 @@ export default class CheckoutActions extends Component { alert: actionAlerts["3"], onReset: this.handlePaymentsReset, payments, - paymentMethods + paymentMethods, + remainingAmountDue } }, { diff --git a/src/components/PaymentsCheckoutAction.js b/src/components/PaymentsCheckoutAction.js index 3966a322be..e03cd6c765 100644 --- a/src/components/PaymentsCheckoutAction.js +++ b/src/components/PaymentsCheckoutAction.js @@ -14,9 +14,14 @@ const ActionCompleteDiv = styled.div` class PaymentsCheckoutAction extends Component { static renderComplete({ payments }) { - const text = payments.map(({ displayName }) => displayName).join(", "); + if (!Array.isArray(payments) || payments.length === 0) return null; + + const paymentLines = payments.map(({ displayName, payment }, index) => ( +
{displayName} {payment.amount ? `- ${payment.amount}` : null}
+ )); + return ( - {text} + {paymentLines} ); } @@ -96,6 +101,11 @@ class PaymentsCheckoutAction extends Component { * Pass in payment objects previously passed to onSubmit */ payments: PropTypes.arrayOf(PropTypes.object), + /** + * If provided, this component will ensure that no new + * payment is added with an `amount` greater than this. + */ + remainingAmountDue: PropTypes.number, /** * Checkout process step number */ @@ -144,15 +154,20 @@ class PaymentsCheckoutAction extends Component { } handleInputComponentSubmit = async ({ amount = null, data, displayName } = {}) => { - const { onSubmit, paymentMethods } = this.props; + const { onSubmit, paymentMethods, remainingAmountDue } = this.props; const { billingAddress, selectedPaymentMethodName } = this.state; const selectedPaymentMethod = paymentMethods.find((method) => method.name === selectedPaymentMethodName); + let cappedPaymentAmount = amount; + if (cappedPaymentAmount && typeof remainingAmountDue === "number") { + cappedPaymentAmount = Math.min(cappedPaymentAmount, remainingAmountDue); + } + await onSubmit({ displayName: displayName || selectedPaymentMethod.displayName, payment: { - amount, + amount: cappedPaymentAmount, billingAddress, data, method: selectedPaymentMethodName @@ -202,6 +217,18 @@ class PaymentsCheckoutAction extends Component { ); } + renderPartialPayments() { + const { components: { InlineAlert }, payments } = this.props; + + if (!Array.isArray(payments) || payments.length === 0) return null; + + const message = payments.map(({ displayName, payment }) => `${displayName} - ${payment.amount}`).join(", "); + + return ( + + ); + } + renderPaymentMethodList() { const { components: { SelectableList }, @@ -249,6 +276,7 @@ class PaymentsCheckoutAction extends Component { {stepNumber}. {label} {alert ? : ""} + {this.renderPartialPayments()} {this.renderPaymentMethodList()} {!!selectedPaymentMethod && !!selectedPaymentMethod.InputComponent && Date: Wed, 9 Jan 2019 11:55:47 -0600 Subject: [PATCH 05/15] chore: updates to temporary components --- src/components/AddressChoice.js | 26 +++++++---- src/components/ExampleIOUPaymentForm.js | 57 +++++++++++++++++------- src/components/PaymentsCheckoutAction.js | 21 +++++---- src/components/StripePaymentInput.js | 24 ++++++---- 4 files changed, 86 insertions(+), 42 deletions(-) diff --git a/src/components/AddressChoice.js b/src/components/AddressChoice.js index b46b186cb5..f2c68c8980 100644 --- a/src/components/AddressChoice.js +++ b/src/components/AddressChoice.js @@ -1,13 +1,14 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { withComponents } from "@reactioncommerce/components-context"; -import { CustomPropTypes } from "@reactioncommerce/components/utils"; +import { addressToString, CustomPropTypes } from "@reactioncommerce/components/utils"; class AddressChoice extends Component { static propTypes = { - addresses: PropTypes.arrayOf(PropTypes.shape({ - address1: PropTypes.string.isRequired - })), + /** + * A list of addresses to show for selection + */ + addresses: CustomPropTypes.addressBook, /** * You can provide a `className` prop that will be applied to the outermost DOM element * rendered by this component. We do not recommend using this for styling purposes, but @@ -43,12 +44,18 @@ class AddressChoice extends Component { * entering one, it will be whatever they have entered so far * and may be partial. */ - onChange: PropTypes.func + onChange: PropTypes.func, + /** + * The label for the "Use a different address" selection item, if it + * is shown. + */ + otherAddressLabel: PropTypes.string }; static defaultProps = { isReadOnly: false, - onChange() {} + onChange() {}, + otherAddressLabel: "Use a different address" }; constructor(props) { @@ -80,7 +87,8 @@ class AddressChoice extends Component { const { addresses, components: { SelectableList }, - isReadOnly + isReadOnly, + otherAddressLabel } = this.props; const { selectedOption } = this.state; @@ -88,13 +96,13 @@ class AddressChoice extends Component { const listOptions = addresses.map((address, index) => ({ id: String(index), - label: address.address1, + label: addressToString(address, { includeFullName: true }), value: String(index) })); listOptions.push({ id: "OTHER", - label: "Use a different billing address", + label: otherAddressLabel, value: "OTHER" }); diff --git a/src/components/ExampleIOUPaymentForm.js b/src/components/ExampleIOUPaymentForm.js index 64d7fa13bc..d04865253e 100644 --- a/src/components/ExampleIOUPaymentForm.js +++ b/src/components/ExampleIOUPaymentForm.js @@ -5,6 +5,23 @@ import { uniqueId } from "lodash"; import { withComponents } from "@reactioncommerce/components-context"; import { CustomPropTypes } from "@reactioncommerce/components/utils"; +/** + * Convert the form document to the object structure + * expected by `PaymentsCheckoutAction` + * @param {Object} Form object + * @returns {Object} Transformed object + */ +function buildResult({ amount, fullName }) { + let floatAmount = amount ? parseFloat(amount) : null; + if (isNaN(floatAmount)) floatAmount = null; + + return { + amount: floatAmount, + data: { fullName }, + displayName: fullName ? `IOU from ${fullName}` : null + }; +} + class ExampleIOUPaymentForm extends Component { static propTypes = { /** @@ -41,6 +58,10 @@ class ExampleIOUPaymentForm extends Component { * Is the payment input being saved? */ isSaving: PropTypes.bool, + /** + * Called as the form fields are changed + */ + onChange: PropTypes.func, /** * When this action's input data switches between being * ready for saving and not ready for saving, this will @@ -56,33 +77,37 @@ class ExampleIOUPaymentForm extends Component { } static defaultProps = { - onReadyForSaveChange() { } + onChange() {}, + onReadyForSaveChange() {}, + onSubmit() {} }; - componentDidMount() { - const { onReadyForSaveChange } = this.props; - onReadyForSaveChange(false); - } - uniqueInstanceIdentifier = uniqueId("ExampleIOUPaymentForm"); submit() { if (this.form) this.form.submit(); } - handleChange = ({ fullName }) => { - const { onReadyForSaveChange } = this.props; - onReadyForSaveChange(!!fullName); + handleChange = (doc) => { + const { onChange, onReadyForSaveChange } = this.props; + + const resultDoc = buildResult(doc); + const stringDoc = JSON.stringify(resultDoc); + if (stringDoc !== this.lastDoc) { + onChange(resultDoc); + } + this.lastDoc = stringDoc; + + const isReady = !!doc.fullName; + if (isReady !== this.lastIsReady) { + onReadyForSaveChange(isReady); + } + this.lastIsReady = isReady; } - handleSubmit = ({ amount, fullName }) => { + handleSubmit = (doc) => { const { onSubmit } = this.props; - - return onSubmit({ - amount: amount ? parseFloat(amount) : null, - data: { fullName }, - displayName: `IOU from ${fullName}` - }); + return onSubmit(buildResult(doc)); } render() { diff --git a/src/components/PaymentsCheckoutAction.js b/src/components/PaymentsCheckoutAction.js index e03cd6c765..2b39ac2030 100644 --- a/src/components/PaymentsCheckoutAction.js +++ b/src/components/PaymentsCheckoutAction.js @@ -4,8 +4,10 @@ import { withComponents } from "@reactioncommerce/components-context"; import styled from "styled-components"; import { addTypographyStyles, CustomPropTypes } from "@reactioncommerce/components/utils"; +const formatMoney = (value) => (value ? `$${value.toFixed(2)}` : "$0.00"); + const Title = styled.h3` - ${addTypographyStyles("PaymentsCheckoutAction", "subheadingTextBold")} + ${addTypographyStyles("PaymentsCheckoutActionTitle", "subheadingTextBold")} `; const ActionCompleteDiv = styled.div` @@ -17,7 +19,7 @@ class PaymentsCheckoutAction extends Component { if (!Array.isArray(payments) || payments.length === 0) return null; const paymentLines = payments.map(({ displayName, payment }, index) => ( -
{displayName} {payment.amount ? `- ${payment.amount}` : null}
+
{displayName}{payment.amount ? ` (${formatMoney(payment.amount)})` : null}
)); return ( @@ -26,9 +28,12 @@ class PaymentsCheckoutAction extends Component { } static propTypes = { - addresses: PropTypes.arrayOf(PropTypes.shape({ - address1: PropTypes.string.isRequired - })), + /** + * Provide the shipping address and any other known addresses. + * The user will be able to choose from these rather than entering + * the billing address, if the billing address is one of them. + */ + addresses: CustomPropTypes.addressBook, /** * Alert object provides alert into to InlineAlert. */ @@ -78,7 +83,7 @@ class PaymentsCheckoutAction extends Component { onReadyForSaveChange: PropTypes.func, /** * When called, the parent should clear all previously submitted - * payments from state + * payments from state. Currently this is called only on mount. */ onReset: PropTypes.func, /** @@ -96,7 +101,7 @@ class PaymentsCheckoutAction extends Component { InputComponent: CustomPropTypes.component, name: PropTypes.string.isRequired, shouldCollectBillingAddress: PropTypes.bool.isRequired - })), + })).isRequired, /** * Pass in payment objects previously passed to onSubmit */ @@ -222,7 +227,7 @@ class PaymentsCheckoutAction extends Component { if (!Array.isArray(payments) || payments.length === 0) return null; - const message = payments.map(({ displayName, payment }) => `${displayName} - ${payment.amount}`).join(", "); + const message = payments.map(({ displayName, payment }) => `${displayName} - ${formatMoney(payment.amount)}`).join(", "); return ( diff --git a/src/components/StripePaymentInput.js b/src/components/StripePaymentInput.js index 9b7410886b..afde36e045 100644 --- a/src/components/StripePaymentInput.js +++ b/src/components/StripePaymentInput.js @@ -5,7 +5,7 @@ import styled from "styled-components"; import { addTypographyStyles, CustomPropTypes } from "@reactioncommerce/components/utils"; const SecureCaption = styled.div` - ${addTypographyStyles("StripePaymentInput", "captionText")} + ${addTypographyStyles("StripePaymentInputCaption", "captionText")} `; const IconLockSpan = styled.span` @@ -63,7 +63,8 @@ class StripePaymentInput extends Component { }; static defaultProps = { - onReadyForSaveChange() { } + onReadyForSaveChange() {}, + onSubmit() {} }; componentDidMount() { @@ -71,7 +72,7 @@ class StripePaymentInput extends Component { onReadyForSaveChange(false); } - submit = async () => { + async submit() { const { onSubmit } = this.props; const { token } = await this._stripe.createToken(); @@ -83,17 +84,22 @@ class StripePaymentInput extends Component { }); } + handleChangeReadyState = (isReady) => { + const { onReadyForSaveChange } = this.props; + + if (isReady !== this.lastIsReady) { + onReadyForSaveChange(isReady); + } + this.lastIsReady = isReady; + } + render() { - const { - className, - components: { iconLock, StripeForm }, - onReadyForSaveChange - } = this.props; + const { className, components: { iconLock, StripeForm } } = this.props; return (
{ this._stripe = stripe; }} /> From 938634538d44db4d4ca10b1624a3044e2045d8ed Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Fri, 11 Jan 2019 17:58:09 -0600 Subject: [PATCH 06/15] chore: switch to library components --- package.json | 2 +- src/components/AddressChoice.js | 133 -------- .../CheckoutActions/CheckoutActions.js | 2 +- src/components/ExampleIOUPaymentForm.js | 148 --------- src/components/PaymentsCheckoutAction.js | 299 ------------------ src/components/StripePaymentInput.js | 113 ------- src/custom/componentsContext.js | 2 +- src/custom/paymentMethods.js | 4 +- yarn.lock | 16 +- 9 files changed, 18 insertions(+), 701 deletions(-) delete mode 100644 src/components/AddressChoice.js delete mode 100644 src/components/ExampleIOUPaymentForm.js delete mode 100644 src/components/PaymentsCheckoutAction.js delete mode 100644 src/components/StripePaymentInput.js diff --git a/package.json b/package.json index 918b5f488f..7398ea24d4 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@material-ui/core": "^3.1.0", - "@reactioncommerce/components": "0.60.1", + "@reactioncommerce/components": "^0.61.0", "@reactioncommerce/components-context": "^1.1.0", "@segment/snippet": "^4.3.1", "apollo-cache-inmemory": "^1.1.11", diff --git a/src/components/AddressChoice.js b/src/components/AddressChoice.js deleted file mode 100644 index f2c68c8980..0000000000 --- a/src/components/AddressChoice.js +++ /dev/null @@ -1,133 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { withComponents } from "@reactioncommerce/components-context"; -import { addressToString, CustomPropTypes } from "@reactioncommerce/components/utils"; - -class AddressChoice extends Component { - static propTypes = { - /** - * A list of addresses to show for selection - */ - addresses: CustomPropTypes.addressBook, - /** - * You can provide a `className` prop that will be applied to the outermost DOM element - * rendered by this component. We do not recommend using this for styling purposes, but - * it can be useful as a selector in some situations. - */ - className: PropTypes.string, - /** - * If you've set up a components context using - * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) - * (recommended), then this prop will come from there automatically. If you have not - * set up a components context or you want to override one of the components in a - * single spot, you can pass in the components prop directly. - */ - components: PropTypes.shape({ - /** - * Pass either the Reaction AddressForm component or your own component that - * accepts compatible props. - */ - AddressForm: CustomPropTypes.component.isRequired, - /** - * A reaction SelectableList component or compatible component. - */ - SelectableList: CustomPropTypes.component.isRequired - }), - /** - * Disable editing? - */ - isReadOnly: PropTypes.bool, - /** - * Called with an address whenever the selected or entered - * address changes. If they selected one, it will be the - * complete address that was passed in `addresses`. If they're - * entering one, it will be whatever they have entered so far - * and may be partial. - */ - onChange: PropTypes.func, - /** - * The label for the "Use a different address" selection item, if it - * is shown. - */ - otherAddressLabel: PropTypes.string - }; - - static defaultProps = { - isReadOnly: false, - onChange() {}, - otherAddressLabel: "Use a different address" - }; - - constructor(props) { - super(props); - - let selectedOption = "OTHER"; - if (Array.isArray(props.addresses) && props.addresses.length > 0) { - selectedOption = "0"; - } - - this.state = { selectedOption }; - } - - handleChangeAddress = (address) => { - this.props.onChange(address); - } - - handleChangeSelection = (selectedOption) => { - const { addresses } = this.props; - - this.setState({ selectedOption }); - - if (selectedOption !== "OTHER" && Array.isArray(addresses)) { - this.props.onChange(addresses[Number(selectedOption)]); - } - } - - renderSelectList() { - const { - addresses, - components: { SelectableList }, - isReadOnly, - otherAddressLabel - } = this.props; - const { selectedOption } = this.state; - - if (!Array.isArray(addresses) || addresses.length === 0) return null; - - const listOptions = addresses.map((address, index) => ({ - id: String(index), - label: addressToString(address, { includeFullName: true }), - value: String(index) - })); - - listOptions.push({ - id: "OTHER", - label: otherAddressLabel, - value: "OTHER" - }); - - return ( - - ); - } - - render() { - const { className, components: { AddressForm }, isReadOnly } = this.props; - const { selectedOption } = this.state; - - return ( -
- {this.renderSelectList()} - {selectedOption === "OTHER" && } -
- ); - } -} - -export default withComponents(AddressChoice); diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 5456a90680..708870eca7 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -5,7 +5,7 @@ import isEqual from "lodash.isequal"; import Actions from "@reactioncommerce/components/CheckoutActions/v1"; import ShippingAddressCheckoutAction from "@reactioncommerce/components/ShippingAddressCheckoutAction/v1"; import FulfillmentOptionsCheckoutAction from "@reactioncommerce/components/FulfillmentOptionsCheckoutAction/v1"; -import PaymentsCheckoutAction from "components/PaymentsCheckoutAction"; +import PaymentsCheckoutAction from "@reactioncommerce/components/PaymentsCheckoutAction/v1"; import FinalReviewCheckoutAction from "@reactioncommerce/components/FinalReviewCheckoutAction/v1"; import withCart from "containers/cart/withCart"; import withAddressValidation from "containers/address/withAddressValidation"; diff --git a/src/components/ExampleIOUPaymentForm.js b/src/components/ExampleIOUPaymentForm.js deleted file mode 100644 index d04865253e..0000000000 --- a/src/components/ExampleIOUPaymentForm.js +++ /dev/null @@ -1,148 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { Form } from "reacto-form"; -import { uniqueId } from "lodash"; -import { withComponents } from "@reactioncommerce/components-context"; -import { CustomPropTypes } from "@reactioncommerce/components/utils"; - -/** - * Convert the form document to the object structure - * expected by `PaymentsCheckoutAction` - * @param {Object} Form object - * @returns {Object} Transformed object - */ -function buildResult({ amount, fullName }) { - let floatAmount = amount ? parseFloat(amount) : null; - if (isNaN(floatAmount)) floatAmount = null; - - return { - amount: floatAmount, - data: { fullName }, - displayName: fullName ? `IOU from ${fullName}` : null - }; -} - -class ExampleIOUPaymentForm extends Component { - static propTypes = { - /** - * You can provide a `className` prop that will be applied to the outermost DOM element - * rendered by this component. We do not recommend using this for styling purposes, but - * it can be useful as a selector in some situations. - */ - className: PropTypes.string, - /** - * If you've set up a components context using - * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) - * (recommended), then this prop will come from there automatically. If you have not - * set up a components context or you want to override one of the components in a - * single spot, you can pass in the components prop directly. - */ - components: PropTypes.shape({ - /** - * Pass either the Reaction ErrorsBlock component or your own component that - * accepts compatible props. - */ - ErrorsBlock: CustomPropTypes.component.isRequired, - /** - * Pass either the Reaction Field component or your own component that - * accepts compatible props. - */ - Field: CustomPropTypes.component.isRequired, - /** - * Pass either the Reaction TextInput component or your own component that - * accepts compatible props. - */ - TextInput: CustomPropTypes.component.isRequired - }), - /** - * Is the payment input being saved? - */ - isSaving: PropTypes.bool, - /** - * Called as the form fields are changed - */ - onChange: PropTypes.func, - /** - * When this action's input data switches between being - * ready for saving and not ready for saving, this will - * be called with `true` (ready) or `false` - */ - onReadyForSaveChange: PropTypes.func, - /** - * Called with an object value when this component's `submit` - * method is called. The object may have `data`, `displayName`, - * and `amount` properties. - */ - onSubmit: PropTypes.func - } - - static defaultProps = { - onChange() {}, - onReadyForSaveChange() {}, - onSubmit() {} - }; - - uniqueInstanceIdentifier = uniqueId("ExampleIOUPaymentForm"); - - submit() { - if (this.form) this.form.submit(); - } - - handleChange = (doc) => { - const { onChange, onReadyForSaveChange } = this.props; - - const resultDoc = buildResult(doc); - const stringDoc = JSON.stringify(resultDoc); - if (stringDoc !== this.lastDoc) { - onChange(resultDoc); - } - this.lastDoc = stringDoc; - - const isReady = !!doc.fullName; - if (isReady !== this.lastIsReady) { - onReadyForSaveChange(isReady); - } - this.lastIsReady = isReady; - } - - handleSubmit = (doc) => { - const { onSubmit } = this.props; - return onSubmit(buildResult(doc)); - } - - render() { - const { - className, - components: { - ErrorsBlock, - Field, - TextInput - }, - isSaving - } = this.props; - - const fullNameInputId = `fullName_${this.uniqueInstanceIdentifier}`; - const amountInputId = `amount_${this.uniqueInstanceIdentifier}`; - - return ( -
{ this.form = formRef; }} - > - - - - - - - - -
- ); - } -} - -export default withComponents(ExampleIOUPaymentForm); diff --git a/src/components/PaymentsCheckoutAction.js b/src/components/PaymentsCheckoutAction.js deleted file mode 100644 index 2b39ac2030..0000000000 --- a/src/components/PaymentsCheckoutAction.js +++ /dev/null @@ -1,299 +0,0 @@ -import React, { Component, Fragment } from "react"; -import PropTypes from "prop-types"; -import { withComponents } from "@reactioncommerce/components-context"; -import styled from "styled-components"; -import { addTypographyStyles, CustomPropTypes } from "@reactioncommerce/components/utils"; - -const formatMoney = (value) => (value ? `$${value.toFixed(2)}` : "$0.00"); - -const Title = styled.h3` - ${addTypographyStyles("PaymentsCheckoutActionTitle", "subheadingTextBold")} -`; - -const ActionCompleteDiv = styled.div` - ${addTypographyStyles("PaymentsCheckoutActionComplete", "bodyText")}; -`; - -class PaymentsCheckoutAction extends Component { - static renderComplete({ payments }) { - if (!Array.isArray(payments) || payments.length === 0) return null; - - const paymentLines = payments.map(({ displayName, payment }, index) => ( -
{displayName}{payment.amount ? ` (${formatMoney(payment.amount)})` : null}
- )); - - return ( - {paymentLines} - ); - } - - static propTypes = { - /** - * Provide the shipping address and any other known addresses. - * The user will be able to choose from these rather than entering - * the billing address, if the billing address is one of them. - */ - addresses: CustomPropTypes.addressBook, - /** - * Alert object provides alert into to InlineAlert. - */ - alert: CustomPropTypes.alert, - /** - * You can provide a `className` prop that will be applied to the outermost DOM element - * rendered by this component. We do not recommend using this for styling purposes, but - * it can be useful as a selector in some situations. - */ - className: PropTypes.string, - /** - * If you've set up a components context using - * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) - * (recommended), then this prop will come from there automatically. If you have not - * set up a components context or you want to override one of the components in a - * single spot, you can pass in the components prop directly. - */ - components: PropTypes.shape({ - /** - * Pass either the Reaction AddressChoice component or your own component that - * accepts compatible props. - */ - AddressChoice: CustomPropTypes.component.isRequired, - /** - * Pass either the Reaction InlineAlert component or your own component that - * accepts compatible props. - */ - InlineAlert: CustomPropTypes.component.isRequired, - /** - * A reaction SelectableList component or compatible component. - */ - SelectableList: CustomPropTypes.component.isRequired - }), - /** - * Is the payment input being saved? - */ - isSaving: PropTypes.bool, - /** - * Label of workflow step - */ - label: PropTypes.string.isRequired, - /** - * When this action's input data switches between being - * ready for saving and not ready for saving, this will - * be called with `true` (ready) or `false` - */ - onReadyForSaveChange: PropTypes.func, - /** - * When called, the parent should clear all previously submitted - * payments from state. Currently this is called only on mount. - */ - onReset: PropTypes.func, - /** - * Called with an object value when this component's `submit` - * method is called. The object has a `payment` property, where - * `payment` is the Payment that should be passed to the `placeOrder` - * mutation, and a `displayName` property. - */ - onSubmit: PropTypes.func, - /** - * List of all payment methods available for this shop / checkout - */ - paymentMethods: PropTypes.arrayOf(PropTypes.shape({ - displayName: PropTypes.string.isRequired, - InputComponent: CustomPropTypes.component, - name: PropTypes.string.isRequired, - shouldCollectBillingAddress: PropTypes.bool.isRequired - })).isRequired, - /** - * Pass in payment objects previously passed to onSubmit - */ - payments: PropTypes.arrayOf(PropTypes.object), - /** - * If provided, this component will ensure that no new - * payment is added with an `amount` greater than this. - */ - remainingAmountDue: PropTypes.number, - /** - * Checkout process step number - */ - stepNumber: PropTypes.number.isRequired - }; - - static defaultProps = { - onReadyForSaveChange() {}, - onReset() {}, - onSubmit() {} - }; - - constructor(props) { - super(props); - - const { addresses, paymentMethods } = props; - - let selectedPaymentMethodName = null; - if (Array.isArray(paymentMethods)) { - const [method] = paymentMethods; - if (method) { - selectedPaymentMethodName = method.name; - } - } - - this.state = { - billingAddress: addresses && addresses[0] ? addresses[0] : null, - inputIsComplete: false, - selectedPaymentMethodName - }; - } - - componentDidMount() { - this.checkIfReadyForSaveChange(); - this.props.onReset(); - } - - _inputComponent = null; - - submit = async () => { - if (this._inputComponent) { - this._inputComponent.submit(); - } else { - this.handleInputComponentSubmit(); - } - } - - handleInputComponentSubmit = async ({ amount = null, data, displayName } = {}) => { - const { onSubmit, paymentMethods, remainingAmountDue } = this.props; - const { billingAddress, selectedPaymentMethodName } = this.state; - - const selectedPaymentMethod = paymentMethods.find((method) => method.name === selectedPaymentMethodName); - - let cappedPaymentAmount = amount; - if (cappedPaymentAmount && typeof remainingAmountDue === "number") { - cappedPaymentAmount = Math.min(cappedPaymentAmount, remainingAmountDue); - } - - await onSubmit({ - displayName: displayName || selectedPaymentMethod.displayName, - payment: { - amount: cappedPaymentAmount, - billingAddress, - data, - method: selectedPaymentMethodName - } - }); - } - - checkIfReadyForSaveChange() { - const { onReadyForSaveChange, paymentMethods } = this.props; - const { billingAddress, inputIsComplete, selectedPaymentMethodName } = this.state; - - const isFilled = billingAddress && - Object.keys(billingAddress).every((key) => (["address2", "company"].indexOf(key) > -1 ? true : billingAddress[key] !== null)); - - const selectedPaymentMethod = paymentMethods.find((method) => method.name === selectedPaymentMethodName); - const isInputReady = !selectedPaymentMethod || !selectedPaymentMethod.InputComponent || inputIsComplete; - - onReadyForSaveChange(!!(isInputReady && isFilled)); - } - - handleAddressChange = (billingAddress = null) => { - this.setState({ billingAddress }, () => { - this.checkIfReadyForSaveChange(); - }); - }; - - handleInputReadyForSaveChange = (inputIsComplete) => { - this.setState({ inputIsComplete }, () => { - this.checkIfReadyForSaveChange(); - }); - } - - handleSelectedPaymentMethodChange = (selectedPaymentMethodName) => { - this.setState({ selectedPaymentMethodName }, () => { - this.checkIfReadyForSaveChange(); - }); - }; - - renderBillingAddressForm() { - const { addresses, components: { AddressChoice }, isSaving } = this.props; - - return ( - - Billing Address - - - ); - } - - renderPartialPayments() { - const { components: { InlineAlert }, payments } = this.props; - - if (!Array.isArray(payments) || payments.length === 0) return null; - - const message = payments.map(({ displayName, payment }) => `${displayName} - ${formatMoney(payment.amount)}`).join(", "); - - return ( - - ); - } - - renderPaymentMethodList() { - const { - components: { SelectableList }, - isSaving, - paymentMethods - } = this.props; - - if (paymentMethods.length < 2) return null; - - const { selectedPaymentMethodName } = this.state; - const options = paymentMethods.map((method) => ({ - id: method.name, - label: method.displayName, - value: method.name - })); - - return ( - - ); - } - - render() { - const { - alert, - className, - components: { InlineAlert }, - isSaving, - label, - paymentMethods, - stepNumber - } = this.props; - - const { selectedPaymentMethodName } = this.state; - const selectedPaymentMethod = paymentMethods.find((method) => method.name === selectedPaymentMethodName); - - return ( -
- - {stepNumber}. {label} - - {alert ? : ""} - {this.renderPartialPayments()} - {this.renderPaymentMethodList()} - {!!selectedPaymentMethod && !!selectedPaymentMethod.InputComponent && - { this._inputComponent = instance; }} - />} - {!!selectedPaymentMethod && !!selectedPaymentMethod.shouldCollectBillingAddress && this.renderBillingAddressForm()} -
- ); - } -} - -export default withComponents(PaymentsCheckoutAction); diff --git a/src/components/StripePaymentInput.js b/src/components/StripePaymentInput.js deleted file mode 100644 index afde36e045..0000000000 --- a/src/components/StripePaymentInput.js +++ /dev/null @@ -1,113 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { withComponents } from "@reactioncommerce/components-context"; -import styled from "styled-components"; -import { addTypographyStyles, CustomPropTypes } from "@reactioncommerce/components/utils"; - -const SecureCaption = styled.div` - ${addTypographyStyles("StripePaymentInputCaption", "captionText")} -`; - -const IconLockSpan = styled.span` - display: inline-block; - height: 20px; - width: 20px; -`; - -const Span = styled.span` - vertical-align: super; -`; - -class StripePaymentInput extends Component { - static propTypes = { - /** - * You can provide a `className` prop that will be applied to the outermost DOM element - * rendered by this component. We do not recommend using this for styling purposes, but - * it can be useful as a selector in some situations. - */ - className: PropTypes.string, - /** - * If you've set up a components context using - * [@reactioncommerce/components-context](https://github.com/reactioncommerce/components-context) - * (recommended), then this prop will come from there automatically. If you have not - * set up a components context or you want to override one of the components in a - * single spot, you can pass in the components prop directly. - */ - components: PropTypes.shape({ - /** - * Secured lock icon - */ - iconLock: PropTypes.node, - /** - * Pass either the Reaction StripeForm component or your own component that - * accepts compatible props. - */ - StripeForm: CustomPropTypes.component.isRequired - }), - /** - * Is the payment input being saved? - */ - isSaving: PropTypes.bool, - /** - * When this action's input data switches between being - * ready for saving and not ready for saving, this will - * be called with `true` (ready) or `false` - */ - onReadyForSaveChange: PropTypes.func, - /** - * Called with an object value when this component's `submit` - * method is called. The object may have `data`, `displayName`, - * and `amount` properties. - */ - onSubmit: PropTypes.func - }; - - static defaultProps = { - onReadyForSaveChange() {}, - onSubmit() {} - }; - - componentDidMount() { - const { onReadyForSaveChange } = this.props; - onReadyForSaveChange(false); - } - - async submit() { - const { onSubmit } = this.props; - const { token } = await this._stripe.createToken(); - - await onSubmit({ - displayName: `${token.card.brand} ending in ${token.card.last4}`, - data: { - stripeTokenId: token.id - } - }); - } - - handleChangeReadyState = (isReady) => { - const { onReadyForSaveChange } = this.props; - - if (isReady !== this.lastIsReady) { - onReadyForSaveChange(isReady); - } - this.lastIsReady = isReady; - } - - render() { - const { className, components: { iconLock, StripeForm } } = this.props; - - return ( -
- { this._stripe = stripe; }} - /> - - {iconLock} Your Information is private and secure. - -
- ); - } -} - -export default withComponents(StripePaymentInput); diff --git a/src/custom/componentsContext.js b/src/custom/componentsContext.js index f5519e33c0..b407a92844 100644 --- a/src/custom/componentsContext.js +++ b/src/custom/componentsContext.js @@ -23,7 +23,7 @@ import iconVisa from "@reactioncommerce/components/svg/iconVisa"; import spinner from "@reactioncommerce/components/svg/spinner"; import Address from "@reactioncommerce/components/Address/v1"; import AddressCapture from "@reactioncommerce/components/AddressCapture/v1"; -import AddressChoice from "components/AddressChoice"; +import AddressChoice from "@reactioncommerce/components/AddressChoice/v1"; import AddressForm from "@reactioncommerce/components/AddressForm/v1"; import AddressReview from "@reactioncommerce/components/AddressReview/v1"; import BadgeOverlay from "@reactioncommerce/components/BadgeOverlay/v1"; diff --git a/src/custom/paymentMethods.js b/src/custom/paymentMethods.js index b904139e34..ac0217e55f 100644 --- a/src/custom/paymentMethods.js +++ b/src/custom/paymentMethods.js @@ -1,5 +1,5 @@ -import ExampleIOUPaymentForm from "../components/ExampleIOUPaymentForm"; -import StripePaymentInput from "../components/StripePaymentInput"; +import ExampleIOUPaymentForm from "@reactioncommerce/components/ExampleIOUPaymentForm/v1"; +import StripePaymentInput from "@reactioncommerce/components/StripePaymentInput/v1"; const paymentMethods = [ { diff --git a/yarn.lock b/yarn.lock index 693aab4d00..5e372eb56c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1024,11 +1024,13 @@ dependencies: hoist-non-react-statics "^3.2.0" -"@reactioncommerce/components@0.60.1": - version "0.60.1" - resolved "https://registry.yarnpkg.com/@reactioncommerce/components/-/components-0.60.1.tgz#e8ae8517453b455c6d90ddab52e7f82aa5f28540" +"@reactioncommerce/components@^0.61.0": + version "0.61.0" + resolved "https://registry.yarnpkg.com/@reactioncommerce/components/-/components-0.61.0.tgz#6a29d2ddd08f992ed2879cc66590216183eb9bdd" + integrity sha512-rdCGmdt0iMKDB5Jb8ySv0fK7fnb51JdAZEruffk+cqrwobcrzABpfbqnr8bkbswG5RyuCMTdZoEAWcz1T+2U8g== dependencies: "@material-ui/core" "^3.1.0" + accounting-js "^1.1.1" lodash.debounce "^4.0.8" lodash.get "^4.4.2" lodash.isempty "^4.4.0" @@ -1231,6 +1233,14 @@ accepts@~1.3.5: mime-types "~2.1.18" negotiator "0.6.1" +accounting-js@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/accounting-js/-/accounting-js-1.1.1.tgz#7fe4b3f70c01ebe0b85c02c5f107f1393b880c9e" + integrity sha1-f+Sz9wwB6+C4XALF8QfxOTuIDJ4= + dependencies: + is-string "^1.0.4" + object-assign "^4.0.1" + acorn-dynamic-import@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-3.0.0.tgz#901ceee4c7faaef7e07ad2a47e890675da50a278" From a22166448a8d259ae4cbb7cedad9a5dd3d7d59e0 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 15 Jan 2019 16:57:10 -0600 Subject: [PATCH 07/15] feat: show amounts next to payment methods on order summary --- src/components/OrderSummary/OrderSummary.js | 2 +- src/custom/layouts.js | 30 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/custom/layouts.js diff --git a/src/components/OrderSummary/OrderSummary.js b/src/components/OrderSummary/OrderSummary.js index c9fd1da08b..e7082c5d3f 100644 --- a/src/components/OrderSummary/OrderSummary.js +++ b/src/components/OrderSummary/OrderSummary.js @@ -94,7 +94,7 @@ class OrderSummary extends Component { {(payments || []).map((payment) => ( - {payment.displayName} + {payment.displayName} ({payment.amount.displayAmount}) ))} diff --git a/src/custom/layouts.js b/src/custom/layouts.js new file mode 100644 index 0000000000..5ae80d1822 --- /dev/null +++ b/src/custom/layouts.js @@ -0,0 +1,30 @@ +import Layout from "components/Layout"; +import { StripeProvider } from "react-stripe-elements"; +import getConfig from "next/config"; + +const { publicRuntimeConfig } = getConfig(); + +/** + * + */ +export function withLayout(pageElement, { route, shop, viewer }) { + if (route === "/checkout" || route === "/login") { + const { stripePublicApiKey } = publicRuntimeConfig; + let stripe = null; + if (stripePublicApiKey && window.Stripe) { + stripe = window.Stripe(stripePublicApiKey); + } + + return ( + + {pageElement} + + ); + } + + return ( + + {pageElement} + + ); +} From 615e00afbbf8450e9f11d0f3cac625c35c7f0268 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 15 Jan 2019 18:48:25 -0600 Subject: [PATCH 08/15] fix: fix math issue calculating remainder due at checkout --- .../CheckoutActions/CheckoutActions.js | 3 ++- src/lib/utils/calculateRemainderDue.js | 14 ++++++++++++++ src/lib/utils/calculateRemainderDue.test.js | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/lib/utils/calculateRemainderDue.js create mode 100644 src/lib/utils/calculateRemainderDue.test.js diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 708870eca7..51d277cbd3 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -17,6 +17,7 @@ import TRACKING from "lib/tracking/constants"; import trackCheckout from "lib/tracking/trackCheckout"; import trackOrder from "lib/tracking/trackOrder"; import trackCheckoutStep from "lib/tracking/trackCheckoutStep"; +import calculateRemainderDue from "lib/utils/calculateRemainderDue"; import { placeOrder } from "../../containers/order/mutations.gql"; import paymentMethods from "../../custom/paymentMethods"; @@ -320,7 +321,7 @@ export default class CheckoutActions extends Component { }, []); const payments = cartStore.checkoutPayments.slice(); - const remainingAmountDue = payments.reduce((val, { payment }) => val - (payment.amount || val), total.amount); + const remainingAmountDue = calculateRemainderDue(payments, total.amount); const actions = [ { diff --git a/src/lib/utils/calculateRemainderDue.js b/src/lib/utils/calculateRemainderDue.js new file mode 100644 index 0000000000..0704e9f2a5 --- /dev/null +++ b/src/lib/utils/calculateRemainderDue.js @@ -0,0 +1,14 @@ +/** + * @summary Given an array of payments and a total amount due, calculates + * how much is still due. + * @param {Object[]} payments Array of PaymentInput objects + * @param {Number} totalDue Total due + * @returns {Number} remainder due + */ +export default function calculateRemainderDue(payments, totalDue) { + const remainingAmountDue = payments.reduce((val, { payment }) => val - (payment.amount || val), totalDue); + // This is an attempt to foil JS floating point math rounding errors. Not sure if this will + // fix every potential issue, but it fixes the one we have a test for. Basically we are + // keeping only three decimal places precision, which is the most any currency has. + return Math.round(remainingAmountDue * 1000) / 1000; +} diff --git a/src/lib/utils/calculateRemainderDue.test.js b/src/lib/utils/calculateRemainderDue.test.js new file mode 100644 index 0000000000..14f36ecf34 --- /dev/null +++ b/src/lib/utils/calculateRemainderDue.test.js @@ -0,0 +1,18 @@ +import calculateRemainderDue from "./calculateRemainderDue"; + +test("calculates correct remainder", () => { + const payments = [ + { payment: { amount: 5.55 } } + ]; + + expect(calculateRemainderDue(payments, 10)).toBe(4.45); +}); + +test("avoids JS math errors", () => { + const payments = [ + { payment: { amount: 100 } }, + { payment: { amount: 79.99 } } + ]; + + expect(calculateRemainderDue(payments, 179.99)).toBe(0); +}); From d16f12ee3ada443f313268367a2280ff2f5a63ec Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 15 Jan 2019 20:16:04 -0600 Subject: [PATCH 09/15] fix: fix wrapping of payment info --- src/components/OrderSummary/OrderSummary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/OrderSummary/OrderSummary.js b/src/components/OrderSummary/OrderSummary.js index e7082c5d3f..5031976d61 100644 --- a/src/components/OrderSummary/OrderSummary.js +++ b/src/components/OrderSummary/OrderSummary.js @@ -92,7 +92,7 @@ class OrderSummary extends Component { {"Payment Method"} - + {(payments || []).map((payment) => ( {payment.displayName} ({payment.amount.displayAmount}) ))} From 50f8701cffc91cc94e7e936aa66ca22e04fc9734 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 15 Jan 2019 20:16:56 -0600 Subject: [PATCH 10/15] chore: clean unnecessary code out of checkoutComplete --- src/pages/checkoutComplete.js | 58 ----------------------------------- 1 file changed, 58 deletions(-) diff --git a/src/pages/checkoutComplete.js b/src/pages/checkoutComplete.js index 4b286e0373..3a3bd182ac 100644 --- a/src/pages/checkoutComplete.js +++ b/src/pages/checkoutComplete.js @@ -1,13 +1,10 @@ import React, { Component, Fragment } from "react"; import PropTypes from "prop-types"; -import { Router } from "routes"; -import { observer } from "mobx-react"; import Helmet from "react-helmet"; import { withStyles } from "@material-ui/core/styles"; import Typography from "@material-ui/core/Typography"; import OrderFulfillmentGroup from "components/OrderFulfillmentGroup"; import PageLoading from "components/PageLoading"; -import withCart from "containers/cart/withCart"; import withOrder from "containers/order/withOrder"; const styles = (theme) => ({ @@ -30,61 +27,18 @@ const styles = (theme) => ({ display: "flex", justifyContent: "center" }, - checkoutTitleContainer: { - alignSelf: "flex-end", - width: "8rem", - [theme.breakpoints.up("md")]: { - width: "10rem" - } - }, - checkoutTitle: { - fontSize: "1.125rem", - color: theme.palette.reaction.black35, - display: "inline", - marginLeft: "0.3rem" - }, flexContainer: { display: "flex", flexDirection: "column" - }, - headerContainer: { - display: "flex", - justifyContent: "space-between", - marginBottom: "2rem" - }, - emptyCartContainer: { - display: "flex", - justifyContent: "center", - alignItems: "center" - }, - emptyCart: { - display: "flex", - justifyContent: "center", - alignItems: "center", - width: 320, - height: 320 - }, - logo: { - color: theme.palette.reaction.reactionBlue, - marginRight: theme.spacing.unit, - borderBottom: `solid 5px ${theme.palette.reaction.reactionBlue200}` } }); -@withCart @withOrder -@observer @withStyles(styles, { withTheme: true }) class CheckoutComplete extends Component { static propTypes = { classes: PropTypes.object, - clearAuthenticatedUsersCart: PropTypes.func.isRequired, - client: PropTypes.object.isRequired, - hasMoreCartItems: PropTypes.bool, isLoadingOrder: PropTypes.bool, - loadMoreCartItems: PropTypes.func, - onChangeCartItemsQuantity: PropTypes.func, - onRemoveCartItems: PropTypes.func, order: PropTypes.shape({ email: PropTypes.string.isRequired, fulfillmentGroups: PropTypes.arrayOf(PropTypes.object).isRequired, @@ -98,18 +52,6 @@ class CheckoutComplete extends Component { theme: PropTypes.object.isRequired }; - state = {}; - - componentDidMount() { - const { clearAuthenticatedUsersCart } = this.props; - - clearAuthenticatedUsersCart(); - } - - handleCartEmptyClick = () => { - Router.pushRoute("/"); - } - renderFulfillmentGroups() { const { classes, order } = this.props; From f9c23a339e271f3ef2673396ba5dbabafc3d032b Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Tue, 15 Jan 2019 20:17:37 -0600 Subject: [PATCH 11/15] feat: respect available payment method list from API --- .../CheckoutActions/CheckoutActions.js | 21 +++++--- src/containers/payment/queries.gql | 6 +++ .../payment/withAvailablePaymentMethods.js | 51 +++++++++++++++++++ src/pages/checkout.js | 50 +++++++++++++++--- 4 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 src/containers/payment/queries.gql create mode 100644 src/containers/payment/withAvailablePaymentMethods.js diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 51d277cbd3..e34d499d99 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -7,7 +7,6 @@ import ShippingAddressCheckoutAction from "@reactioncommerce/components/Shipping import FulfillmentOptionsCheckoutAction from "@reactioncommerce/components/FulfillmentOptionsCheckoutAction/v1"; import PaymentsCheckoutAction from "@reactioncommerce/components/PaymentsCheckoutAction/v1"; import FinalReviewCheckoutAction from "@reactioncommerce/components/FinalReviewCheckoutAction/v1"; -import withCart from "containers/cart/withCart"; import withAddressValidation from "containers/address/withAddressValidation"; import Dialog from "@material-ui/core/Dialog"; import PageLoading from "components/PageLoading"; @@ -19,7 +18,6 @@ import trackOrder from "lib/tracking/trackOrder"; import trackCheckoutStep from "lib/tracking/trackCheckoutStep"; import calculateRemainderDue from "lib/utils/calculateRemainderDue"; import { placeOrder } from "../../containers/order/mutations.gql"; -import paymentMethods from "../../custom/paymentMethods"; const { CHECKOUT_STARTED, @@ -30,7 +28,6 @@ const { } = TRACKING; @withAddressValidation -@withCart @track() @observer export default class CheckoutActions extends Component { @@ -42,13 +39,15 @@ export default class CheckoutActions extends Component { checkout: PropTypes.object, email: PropTypes.string, items: PropTypes.array - }), + }).isRequired, cartStore: PropTypes.object, checkoutMutations: PropTypes.shape({ onSetFulfillmentOption: PropTypes.func.isRequired, onSetShippingAddress: PropTypes.func.isRequired }), - orderEmailAddress: PropTypes.string.isRequired + clearAuthenticatedUsersCart: PropTypes.func.isRequired, + orderEmailAddress: PropTypes.string.isRequired, + paymentMethods: PropTypes.array }; state = { @@ -233,7 +232,7 @@ export default class CheckoutActions extends Component { }; placeOrder = async (order) => { - const { cartStore, client: apolloClient } = this.props; + const { cartStore, clearAuthenticatedUsersCart, client: apolloClient } = this.props; // Payments can have `null` amount to mean "remaining". let remainingAmountDue = order.fulfillmentGroups.reduce((sum, group) => sum + group.totalPrice, 0); @@ -258,6 +257,7 @@ export default class CheckoutActions extends Component { // anonymous cart credentials from cookie since it will be // deleted on the server. cartStore.clearAnonymousCartCredentials(); + clearAuthenticatedUsersCart(); // Also destroy the collected and cached payment input cartStore.resetCheckoutPayments(); @@ -298,8 +298,13 @@ export default class CheckoutActions extends Component { }; render() { - const { addressValidation, addressValidationResults, cart, cartStore } = this.props; - if (!cart) return null; + const { + addressValidation, + addressValidationResults, + cart, + cartStore, + paymentMethods + } = this.props; const { checkout: { fulfillmentGroups, summary }, items } = cart; const { actionAlerts, hasPaymentError } = this.state; diff --git a/src/containers/payment/queries.gql b/src/containers/payment/queries.gql new file mode 100644 index 0000000000..14b03ae15a --- /dev/null +++ b/src/containers/payment/queries.gql @@ -0,0 +1,6 @@ +# Get available payment methods for a shop +query availablePaymentMethods($shopId: ID!) { + availablePaymentMethods(shopId: $shopId) { + name + } +} diff --git a/src/containers/payment/withAvailablePaymentMethods.js b/src/containers/payment/withAvailablePaymentMethods.js new file mode 100644 index 0000000000..bad386c7a1 --- /dev/null +++ b/src/containers/payment/withAvailablePaymentMethods.js @@ -0,0 +1,51 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Query, withApollo } from "react-apollo"; +import hoistNonReactStatic from "hoist-non-react-statics"; +import { availablePaymentMethods as availablePaymentMethodsQuery } from "./queries.gql"; + +/** + * withAvailablePaymentMethods higher order query component for fetching an order + * @name WithAvailablePaymentMethods + * @param {React.Component} Component to decorate + * @returns {React.Component} - Component with `cart` props and callbacks + */ +export default function withAvailablePaymentMethods(Component) { + @withApollo + class WithAvailablePaymentMethods extends React.Component { + static propTypes = { + cart: PropTypes.shape({ + shop: PropTypes.shape({ + _id: PropTypes.string.isRequired + }).isRequired + }) + } + + render() { + const { cart } = this.props; + + const isReadyToLoad = !!cart; + const variables = { shopId: cart && cart.shop._id }; + + return ( + + {({ loading: isLoadingAvailablePaymentMethods, data }) => { + const { availablePaymentMethods } = data || {}; + + return ( + + ); + }} + + ); + } + } + + hoistNonReactStatic(WithAvailablePaymentMethods, Component); + + return WithAvailablePaymentMethods; +} diff --git a/src/pages/checkout.js b/src/pages/checkout.js index bcecb43ba3..be82f52bdb 100644 --- a/src/pages/checkout.js +++ b/src/pages/checkout.js @@ -15,10 +15,13 @@ import ShopLogo from "@reactioncommerce/components/ShopLogo/v1"; import CartIcon from "mdi-material-ui/Cart"; import ChevronLeftIcon from "mdi-material-ui/ChevronLeft"; import LockIcon from "mdi-material-ui/Lock"; -import withCart from "containers/cart/withCart"; import Link from "components/Link"; import CheckoutSummary from "components/CheckoutSummary"; import PageLoading from "components/PageLoading"; +import withCart from "containers/cart/withCart"; +import withAvailablePaymentMethods from "containers/payment/withAvailablePaymentMethods"; +import logger from "lib/logger"; +import definedPaymentMethods from "../custom/paymentMethods"; const styles = (theme) => ({ checkoutActions: { @@ -130,18 +133,26 @@ const styles = (theme) => ({ const hasIdentityCheck = (cart) => !!((cart && cart.account !== null) || (cart && cart.email)); @withCart +@withAvailablePaymentMethods @observer @withStyles(styles, { withTheme: true }) class Checkout extends Component { static propTypes = { + availablePaymentMethods: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string.isRequired + })), cart: PropTypes.shape({ account: PropTypes.object, checkout: PropTypes.object, email: PropTypes.string, items: PropTypes.array }), + cartStore: PropTypes.object, + checkoutMutations: PropTypes.object, classes: PropTypes.object, + clearAuthenticatedUsersCart: PropTypes.func, hasMoreCartItems: PropTypes.bool, + isLoadingAvailablePaymentMethods: PropTypes.bool, isLoadingCart: PropTypes.bool, loadMoreCartItems: PropTypes.func, onChangeCartItemsQuantity: PropTypes.func, @@ -284,17 +295,18 @@ class Checkout extends Component { renderCheckoutActions() { const { + availablePaymentMethods, classes, cart, + cartStore, + checkoutMutations, + clearAuthenticatedUsersCart, hasMoreCartItems, - isLoadingCart, loadMoreCartItems, onRemoveCartItems, onChangeCartItemsQuantity } = this.props; - if (isLoadingCart) return null; - if (!cart || (cart && Array.isArray(cart.items) && cart.items.length === 0)) { return (
@@ -310,6 +322,11 @@ class Checkout extends Component { const hasAccount = !!cart.account; const orderEmailAddress = (hasAccount && Array.isArray(cart.account.emailRecords) && cart.account.emailRecords[0].address) || cart.email; + // Filter the hard-coded definedPaymentMethods list from the client to remove any + // payment methods that were not returned from the API as currently available. + const paymentMethods = definedPaymentMethods.filter((method) => + !!availablePaymentMethods.find((availableMethod) => availableMethod.name === method.name)); + return (
@@ -320,7 +337,14 @@ class Checkout extends Component { {orderEmailAddress ? ( ) : null} - +
@@ -344,8 +368,20 @@ class Checkout extends Component { } render() { - const { isLoadingCart, cart } = this.props; - if (isLoadingCart || !cart) return ; + const { + availablePaymentMethods, + cart, + isLoadingCart, + isLoadingAvailablePaymentMethods + } = this.props; + if (isLoadingCart || isLoadingAvailablePaymentMethods) { + return ; + } + + if (cart && (!Array.isArray(availablePaymentMethods) || availablePaymentMethods.length === 0)) { + logger.error("API returned no available payment methods"); + return null; + } return ( From a757d30720f94bc722b75a4ace4259d8d44537a8 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Wed, 16 Jan 2019 07:09:31 -0600 Subject: [PATCH 12/15] test: update tests --- .../CheckoutSummary.test.js.snap | 66 ++--------------- .../OrderFulfillmentGroup.test.js | 4 ++ .../OrderFulfillmentGroup.test.js.snap | 71 +++---------------- .../OrderSummary/OrderSummary.test.js | 4 ++ .../__snapshots__/OrderSummary.test.js.snap | 71 +++---------------- 5 files changed, 31 insertions(+), 185 deletions(-) diff --git a/src/components/CheckoutSummary/__snapshots__/CheckoutSummary.test.js.snap b/src/components/CheckoutSummary/__snapshots__/CheckoutSummary.test.js.snap index a06deb864b..b73a5666fa 100644 --- a/src/components/CheckoutSummary/__snapshots__/CheckoutSummary.test.js.snap +++ b/src/components/CheckoutSummary/__snapshots__/CheckoutSummary.test.js.snap @@ -271,26 +271,6 @@ exports[`basic snapshot 1`] = ` } .c19 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 0; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; -webkit-font-smoothing: antialiased; color: #3c3c3c; font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; @@ -307,42 +287,6 @@ exports[`basic snapshot 1`] = ` } .c21 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 1px; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - text-align: right; -} - -.c22 { -webkit-font-smoothing: antialiased; color: #3c3c3c; font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; @@ -644,7 +588,7 @@ exports[`basic snapshot 1`] = ` Item total $118 @@ -656,7 +600,7 @@ exports[`basic snapshot 1`] = ` Shipping @@ -666,7 +610,7 @@ exports[`basic snapshot 1`] = ` Tax - @@ -678,10 +622,10 @@ exports[`basic snapshot 1`] = ` Order total $118 diff --git a/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.test.js b/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.test.js index 5f35e4b6c8..9a4613fe00 100644 --- a/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.test.js +++ b/src/components/OrderFulfillmentGroup/OrderFulfillmentGroup.test.js @@ -65,6 +65,10 @@ const testFulfillmentGroup = { }; const testPayments = [{ + _id: "TEST", + amount: { + displayAmount: "$10.00" + }, displayName: "Example Payment" }]; diff --git a/src/components/OrderFulfillmentGroup/__snapshots__/OrderFulfillmentGroup.test.js.snap b/src/components/OrderFulfillmentGroup/__snapshots__/OrderFulfillmentGroup.test.js.snap index 11e920a861..55251a1c23 100644 --- a/src/components/OrderFulfillmentGroup/__snapshots__/OrderFulfillmentGroup.test.js.snap +++ b/src/components/OrderFulfillmentGroup/__snapshots__/OrderFulfillmentGroup.test.js.snap @@ -575,26 +575,6 @@ Array [ } .c3 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 0; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; -webkit-font-smoothing: antialiased; color: #3c3c3c; font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; @@ -611,42 +591,6 @@ Array [ } .c5 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 1px; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - text-align: right; -} - -.c6 { -webkit-font-smoothing: antialiased; color: #3c3c3c; font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; @@ -689,12 +633,15 @@ Array [
@@ -716,7 +663,7 @@ Array [ Item total $118 @@ -728,7 +675,7 @@ Array [ Shipping @@ -738,7 +685,7 @@ Array [ Tax - @@ -750,10 +697,10 @@ Array [ Order total $118 diff --git a/src/components/OrderSummary/OrderSummary.test.js b/src/components/OrderSummary/OrderSummary.test.js index 4daad3fd2d..59dd54ab09 100644 --- a/src/components/OrderSummary/OrderSummary.test.js +++ b/src/components/OrderSummary/OrderSummary.test.js @@ -65,6 +65,10 @@ const testFulfillmentGroup = { }; const testPayments = [{ + _id: "TEST", + amount: { + displayAmount: "$10.00" + }, displayName: "Example Payment" }]; diff --git a/src/components/OrderSummary/__snapshots__/OrderSummary.test.js.snap b/src/components/OrderSummary/__snapshots__/OrderSummary.test.js.snap index f529daee49..37fe63d7d6 100644 --- a/src/components/OrderSummary/__snapshots__/OrderSummary.test.js.snap +++ b/src/components/OrderSummary/__snapshots__/OrderSummary.test.js.snap @@ -58,26 +58,6 @@ exports[`basic snapshot 1`] = ` } .c3 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 0; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; -webkit-font-smoothing: antialiased; color: #3c3c3c; font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; @@ -94,42 +74,6 @@ exports[`basic snapshot 1`] = ` } .c5 { - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - border-top-color: #e6e6e6; - border-top-style: solid; - border-top-width: 1px; - padding-bottom: 8px; - padding-left: 0; - padding-right: 0; - padding-top: 8px; - text-align: left; - -webkit-font-smoothing: antialiased; - color: #3c3c3c; - font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; - font-size: 16px; - font-style: normal; - font-stretch: normal; - font-weight: 400; - -webkit-letter-spacing: .03em; - -moz-letter-spacing: .03em; - -ms-letter-spacing: .03em; - letter-spacing: .03em; - line-height: 1; - text-align: right; -} - -.c6 { -webkit-font-smoothing: antialiased; color: #3c3c3c; font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,sans-serif; @@ -169,12 +113,15 @@ exports[`basic snapshot 1`] = `
@@ -196,7 +143,7 @@ exports[`basic snapshot 1`] = ` Item total $118 @@ -208,7 +155,7 @@ exports[`basic snapshot 1`] = ` Shipping @@ -218,7 +165,7 @@ exports[`basic snapshot 1`] = ` Tax - @@ -230,10 +177,10 @@ exports[`basic snapshot 1`] = ` Order total $118 From 933483b6317f726e1e86c7338a951320280ea4d7 Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 17 Jan 2019 16:25:40 -0600 Subject: [PATCH 13/15] fix: render checkout with message when no payment methods --- src/components/CheckoutActions/CheckoutActions.js | 15 ++++++++++++++- src/pages/checkout.js | 9 +-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index e34d499d99..187407ad05 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -1,12 +1,14 @@ import React, { Fragment, Component } from "react"; import PropTypes from "prop-types"; import { observer } from "mobx-react"; +import styled from "styled-components"; import isEqual from "lodash.isequal"; import Actions from "@reactioncommerce/components/CheckoutActions/v1"; import ShippingAddressCheckoutAction from "@reactioncommerce/components/ShippingAddressCheckoutAction/v1"; import FulfillmentOptionsCheckoutAction from "@reactioncommerce/components/FulfillmentOptionsCheckoutAction/v1"; import PaymentsCheckoutAction from "@reactioncommerce/components/PaymentsCheckoutAction/v1"; import FinalReviewCheckoutAction from "@reactioncommerce/components/FinalReviewCheckoutAction/v1"; +import { addTypographyStyles } from "@reactioncommerce/components/utils"; import withAddressValidation from "containers/address/withAddressValidation"; import Dialog from "@material-ui/core/Dialog"; import PageLoading from "components/PageLoading"; @@ -27,6 +29,12 @@ const { PAYMENT_INFO_ENTERED } = TRACKING; +const MessageDiv = styled.div` + ${addTypographyStyles("NoPaymentMethodsMessage", "bodyText")} +`; + +const NoPaymentMethodsMessage = () => No payment methods available; + @withAddressValidation @track() @observer @@ -328,6 +336,11 @@ export default class CheckoutActions extends Component { const payments = cartStore.checkoutPayments.slice(); const remainingAmountDue = calculateRemainderDue(payments, total.amount); + let PaymentComponent = PaymentsCheckoutAction; + if (!Array.isArray(paymentMethods) || paymentMethods.length === 0) { + PaymentComponent = NoPaymentMethodsMessage; + } + const actions = [ { id: "1", @@ -363,7 +376,7 @@ export default class CheckoutActions extends Component { completeLabel: "Payment information", incompleteLabel: "Payment information", status: remainingAmountDue === 0 && !hasPaymentError ? "complete" : "incomplete", - component: PaymentsCheckoutAction, + component: PaymentComponent, onSubmit: this.handlePaymentSubmit, props: { addresses, diff --git a/src/pages/checkout.js b/src/pages/checkout.js index be82f52bdb..baf8934eb6 100644 --- a/src/pages/checkout.js +++ b/src/pages/checkout.js @@ -20,7 +20,6 @@ import CheckoutSummary from "components/CheckoutSummary"; import PageLoading from "components/PageLoading"; import withCart from "containers/cart/withCart"; import withAvailablePaymentMethods from "containers/payment/withAvailablePaymentMethods"; -import logger from "lib/logger"; import definedPaymentMethods from "../custom/paymentMethods"; const styles = (theme) => ({ @@ -369,20 +368,14 @@ class Checkout extends Component { render() { const { - availablePaymentMethods, - cart, isLoadingCart, isLoadingAvailablePaymentMethods } = this.props; + if (isLoadingCart || isLoadingAvailablePaymentMethods) { return ; } - if (cart && (!Array.isArray(availablePaymentMethods) || availablePaymentMethods.length === 0)) { - logger.error("API returned no available payment methods"); - return null; - } - return ( {this.renderCheckoutHead()} From c86ae0a72c3151730db8e8ae24d6a47cf1c1a34e Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 17 Jan 2019 17:43:17 -0600 Subject: [PATCH 14/15] chore: remove unfinished file --- src/custom/layouts.js | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 src/custom/layouts.js diff --git a/src/custom/layouts.js b/src/custom/layouts.js deleted file mode 100644 index 5ae80d1822..0000000000 --- a/src/custom/layouts.js +++ /dev/null @@ -1,30 +0,0 @@ -import Layout from "components/Layout"; -import { StripeProvider } from "react-stripe-elements"; -import getConfig from "next/config"; - -const { publicRuntimeConfig } = getConfig(); - -/** - * - */ -export function withLayout(pageElement, { route, shop, viewer }) { - if (route === "/checkout" || route === "/login") { - const { stripePublicApiKey } = publicRuntimeConfig; - let stripe = null; - if (stripePublicApiKey && window.Stripe) { - stripe = window.Stripe(stripePublicApiKey); - } - - return ( - - {pageElement} - - ); - } - - return ( - - {pageElement} - - ); -} From 4affe1bb74999f610ac55ca6008ed6963b35028d Mon Sep 17 00:00:00 2001 From: Eric Dobbertin Date: Thu, 17 Jan 2019 17:43:26 -0600 Subject: [PATCH 15/15] chore: fix lint --- src/components/CheckoutActions/CheckoutActions.js | 1 + src/containers/tags/withTag.js | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 187407ad05..3d3e93cdbb 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -1,3 +1,4 @@ +/* eslint-disable react/no-multi-comp */ import React, { Fragment, Component } from "react"; import PropTypes from "prop-types"; import { observer } from "mobx-react"; diff --git a/src/containers/tags/withTag.js b/src/containers/tags/withTag.js index 424e02e3d6..a1aa514482 100644 --- a/src/containers/tags/withTag.js +++ b/src/containers/tags/withTag.js @@ -19,12 +19,17 @@ export default function withTag(Component) { /** * slug used to obtain tag info */ - router: PropTypes.object.isRequired + router: PropTypes.object.isRequired, + routingStore: PropTypes.shape({ + tagId: PropTypes.string + }).isRequired } render() { - const { router: { query: { slug: slugFromQueryParam } } } = this.props; - const { tagId } = this.props.routingStore; + const { + router: { query: { slug: slugFromQueryParam } }, + routingStore: { tagId } + } = this.props; const slugOrId = slugFromQueryParam || tagId;