mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-25 22:37:18 +10:00
Merge pull request #1138 from sharetribe/save-credit-card
Save payment card
This commit is contained in:
commit
239914cab0
58 changed files with 3119 additions and 502 deletions
27
CHANGELOG.md
27
CHANGELOG.md
|
|
@ -14,6 +14,33 @@ way to update this template, but currently, we follow a pattern:
|
|||
|
||||
## Upcoming version 2019-XX-XX
|
||||
|
||||
## [v3.3.0] 2019-08-22
|
||||
|
||||
- [add] Saving payment card after payment or without initial payment. This release contains quite a
|
||||
lot changes to many files. This includes:
|
||||
|
||||
- UI changes to `CheckoutPage` for showing the saved payment method
|
||||
- One more step to `handlePaymentIntent` flow on `CheckoutPage` if the user decides to save the
|
||||
payment card
|
||||
- Showing error notification on `TransactionPage` if saving the payment method has failed
|
||||
- Use Flex SDK v1.5.0 which has new endpoints for creating Stripe Customer and using Stripe
|
||||
SetupIntents
|
||||
- Add `handleCardSetup` function to `stripe.duck.js`
|
||||
- New shared duck file `paymentMethods.duck.js` for handling saving, deleting and replacing the
|
||||
payment method
|
||||
- New page `PaymentMethodsPage` in user's account settings
|
||||
- `StripePaymenAddress` used in `StripePaymentForm` is now a separate component used also in new
|
||||
`PaymentMethodsForm`
|
||||
- New `LayoutWrapperAccountSettingsSideNav` component which is used in account settings pages:
|
||||
`ContactDetailsPage`, `PasswordChangePage`, `PayoutPreferencesPage`, `PaymentMethodsPage`
|
||||
|
||||
[#1138](https://github.com/sharetribe/flex-template-web/pull/1138)
|
||||
|
||||
Read more from Flex docs:
|
||||
[How saving payment card works in FTW](https://www.sharetribe.com/docs/background/save-payment-card/)
|
||||
|
||||
[v3.3.0]: https://github.com/sharetribe/flex-template-web/compare/v3.2.0...v3.2.1
|
||||
|
||||
## [v3.2.1] 2019-08-22
|
||||
|
||||
- [fix] On `ListingPage` align avatar with the left side of the content and fix content width so
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "app",
|
||||
"version": "3.2.1",
|
||||
"version": "3.3.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
"redux-thunk": "^2.3.0",
|
||||
"sanitize.css": "^5.0.0",
|
||||
"seedrandom": "^2.4.4",
|
||||
"sharetribe-flex-sdk": "^1.4.1",
|
||||
"sharetribe-flex-sdk": "^1.5.0",
|
||||
"sharetribe-scripts": "3.0.0",
|
||||
"smoothscroll-polyfill": "^0.4.0",
|
||||
"source-map-support": "^0.5.9",
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root"><!--!body--></div>
|
||||
<div id="portal-root"></div>
|
||||
<script>
|
||||
(function(document){
|
||||
// Check if Promise exists in window
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@
|
|||
stroke: var(--marketplaceColor);
|
||||
}
|
||||
|
||||
/* successColor version */
|
||||
&:hover + label .boxSuccess,
|
||||
&:focus + label .boxSuccess,
|
||||
&:checked + label .boxSuccess {
|
||||
stroke: var(--successColor);
|
||||
}
|
||||
|
||||
/* Display the "check" when checked */
|
||||
&:checked + label .checked {
|
||||
display: inline;
|
||||
|
|
@ -24,6 +31,13 @@
|
|||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
/* Display the "check" when checked */
|
||||
&:checked + label .checkedSuccess {
|
||||
display: inline;
|
||||
stroke: var(--successColor);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
/* Hightlight the text on checked, hover and focus */
|
||||
&:focus + label .text,
|
||||
&:hover + label .text,
|
||||
|
|
@ -54,12 +68,19 @@
|
|||
display: none;
|
||||
fill: var(--marketplaceColor);
|
||||
}
|
||||
.checkedSuccess {
|
||||
display: none;
|
||||
fill: var(--successColor);
|
||||
}
|
||||
|
||||
.boxSuccess,
|
||||
.box {
|
||||
stroke: var(--matterColorAnti);
|
||||
}
|
||||
|
||||
.text {
|
||||
}
|
||||
.textRoot {
|
||||
@apply --marketplaceListingAttributeFontStyles;
|
||||
color: var(--matterColor);
|
||||
margin-top: -1px;
|
||||
|
|
|
|||
|
|
@ -6,16 +6,17 @@ import { Field } from 'react-final-form';
|
|||
import css from './FieldCheckbox.css';
|
||||
|
||||
const IconCheckbox = props => {
|
||||
const { className, checkedClassName, boxClassName } = props;
|
||||
return (
|
||||
<svg className={props.className} width="14" height="14" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg className={className} width="14" height="14" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<g transform="translate(2 2)">
|
||||
<path
|
||||
className={css.checked}
|
||||
className={checkedClassName || css.checked}
|
||||
d="M9.9992985 1.5048549l-.0194517 6.9993137C9.977549 9.3309651 9.3066522 10 8.4798526 10H1.5001008c-.8284271 0-1.5-.6715729-1.5-1.5l-.000121-7c0-.8284271.6715728-1.5 1.5-1.5h.000121l6.9993246.0006862c.8284272.000067 1.4999458.671694 1.499879 1.5001211a1.5002208 1.5002208 0 0 1-.0000059.0040476z"
|
||||
/>
|
||||
<path
|
||||
className={css.box}
|
||||
className={boxClassName || css.box}
|
||||
strokeWidth="2"
|
||||
d="M10.9992947 1.507634l-.0194518 6.9993137C10.9760133 9.8849417 9.8578519 11 8.4798526 11H1.5001008c-1.3807119 0-2.5-1.1192881-2.5-2.4999827L-1.0000202 1.5c0-1.3807119 1.119288-2.5 2.500098-2.5l6.9994284.0006862c1.3807118.0001115 2.4999096 1.11949 2.4997981 2.5002019-.0000018.003373-.0000018.003373-.0000096.0067458z"
|
||||
/>
|
||||
|
|
@ -29,12 +30,21 @@ const IconCheckbox = props => {
|
|||
);
|
||||
};
|
||||
|
||||
IconCheckbox.defaultProps = { className: null };
|
||||
IconCheckbox.defaultProps = { className: null, checkedClassName: null, boxClassName: null };
|
||||
|
||||
IconCheckbox.propTypes = { className: string };
|
||||
IconCheckbox.propTypes = { className: string, checkedClassName: string, boxClassName: string };
|
||||
|
||||
const FieldCheckboxComponent = props => {
|
||||
const { rootClassName, className, svgClassName, id, label, ...rest } = props;
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
svgClassName,
|
||||
textClassName,
|
||||
id,
|
||||
label,
|
||||
useSuccessColor,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const checkboxProps = {
|
||||
|
|
@ -45,14 +55,21 @@ const FieldCheckboxComponent = props => {
|
|||
...rest,
|
||||
};
|
||||
|
||||
const successColorVariantMaybe = useSuccessColor
|
||||
? {
|
||||
checkedClassName: css.checkedSuccess,
|
||||
boxClassName: css.boxSuccess,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
<Field {...checkboxProps} />
|
||||
<label htmlFor={id} className={css.label}>
|
||||
<span className={css.checkboxWrapper}>
|
||||
<IconCheckbox className={svgClassName} />
|
||||
<IconCheckbox className={svgClassName} {...successColorVariantMaybe} />
|
||||
</span>
|
||||
<span className={css.text}>{label}</span>
|
||||
<span className={classNames(css.text, textClassName || css.textRoot)}>{label}</span>
|
||||
</label>
|
||||
</span>
|
||||
);
|
||||
|
|
@ -62,6 +79,7 @@ FieldCheckboxComponent.defaultProps = {
|
|||
className: null,
|
||||
rootClassName: null,
|
||||
svgClassName: null,
|
||||
textClassName: null,
|
||||
label: null,
|
||||
};
|
||||
|
||||
|
|
@ -69,6 +87,7 @@ FieldCheckboxComponent.propTypes = {
|
|||
className: string,
|
||||
rootClassName: string,
|
||||
svgClassName: string,
|
||||
textClassName: string,
|
||||
|
||||
// Id is needed to connect the label with input.
|
||||
id: string.isRequired,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import css from './IconArrowHead.css';
|
|||
|
||||
const DIRECTION_RIGHT = 'right';
|
||||
const DIRECTION_LEFT = 'left';
|
||||
const DIRECTION_DOWN = 'down';
|
||||
const DIRECTION_UP = 'up';
|
||||
const SIZE_BIG = 'big';
|
||||
const SIZE_SMALL = 'small';
|
||||
|
||||
|
|
@ -15,6 +17,8 @@ const IconArrowHead = props => {
|
|||
|
||||
const isRight = direction === DIRECTION_RIGHT;
|
||||
const isLeft = direction === DIRECTION_LEFT;
|
||||
const isDown = direction === DIRECTION_DOWN;
|
||||
const isUp = direction === DIRECTION_UP;
|
||||
const isBig = size === SIZE_BIG;
|
||||
const isSmall = size === SIZE_SMALL;
|
||||
|
||||
|
|
@ -48,6 +52,36 @@ const IconArrowHead = props => {
|
|||
/>
|
||||
</svg>
|
||||
);
|
||||
} else if (isDown && isSmall) {
|
||||
return (
|
||||
<svg
|
||||
className={classes}
|
||||
width="13"
|
||||
height="9"
|
||||
viewBox="0 0 13 9"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.03 6.805c.26.26.68.26.94 0l5.335-5.333a.668.668 0 0 0-.943-.943L6.5 5.39 1.638.53a.666.666 0 1 0-.943.942L6.03 6.805z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
} else if (isUp && isSmall) {
|
||||
return (
|
||||
<svg
|
||||
className={classes}
|
||||
width="13"
|
||||
height="9"
|
||||
viewBox="0 0 13 9"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.97.195a.664.664 0 0 0-.94 0L.695 5.528a.668.668 0 0 0 .943.943L6.5 1.61l4.862 4.86a.666.666 0 1 0 .943-.942L6.97.195z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
} else if (isRight && isBig) {
|
||||
return (
|
||||
<svg
|
||||
|
|
@ -78,6 +112,36 @@ const IconArrowHead = props => {
|
|||
/>
|
||||
</svg>
|
||||
);
|
||||
} else if (isDown && isBig) {
|
||||
return (
|
||||
<svg
|
||||
className={classes}
|
||||
width="15"
|
||||
height="11"
|
||||
viewBox="0 0 15 11"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.5 1.1C.5.93.565.76.692.642a.508.508 0 0 1 .764.07L7 7.972 12.545.71a.506.506 0 0 1 .763-.07c.23.214.257.592.064.846l-5.958 7.8A.524.524 0 0 1 7 9.498a.522.522 0 0 1-.414-.212l-5.958-7.8A.638.638 0 0 1 .5 1.098"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
} else if (isUp && isBig) {
|
||||
return (
|
||||
<svg
|
||||
className={classes}
|
||||
width="15"
|
||||
height="11"
|
||||
viewBox="0 0 15 11"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.5 8.9c0 .17-.065.34-.192.458a.508.508 0 0 1-.764-.07L7 2.028 1.455 9.29a.506.506 0 0 1-.763.07.644.644 0 0 1-.064-.846l5.958-7.8A.524.524 0 0 1 7 .502c.16 0 .31.077.414.212l5.958 7.8c.086.113.128.25.128.388"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -90,7 +154,7 @@ IconArrowHead.defaultProps = {
|
|||
IconArrowHead.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
direction: oneOf([DIRECTION_RIGHT, DIRECTION_LEFT]).isRequired,
|
||||
direction: oneOf([DIRECTION_RIGHT, DIRECTION_LEFT, DIRECTION_DOWN, DIRECTION_UP]).isRequired,
|
||||
size: oneOf([SIZE_BIG, SIZE_SMALL]),
|
||||
};
|
||||
|
||||
|
|
|
|||
4
src/components/IconCard/IconCard.css
Normal file
4
src/components/IconCard/IconCard.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
}
|
||||
264
src/components/IconCard/IconCard.js
Normal file
264
src/components/IconCard/IconCard.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,19 +4,38 @@ import classNames from 'classnames';
|
|||
|
||||
import css from './IconCheckMark.css';
|
||||
|
||||
const SIZE_SMALL = 'small';
|
||||
const SIZE_BIG = 'big';
|
||||
|
||||
const IconCheckmark = props => {
|
||||
const { rootClassName, className } = props;
|
||||
const { rootClassName, className, size } = props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
return (
|
||||
<svg className={classes} strokeWidth="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.6 1.2c-.4-.3-1-.2-1.3.2L7.8 19l-5.2-5c-.4-.4-1-.4-1.3 0-.4.3-.4.8 0 1l6 5.6.6.2s.2 0 .4-.4l14.3-18c.3-.5.2-1-.2-1" />
|
||||
</svg>
|
||||
);
|
||||
if (size === SIZE_SMALL) {
|
||||
return (
|
||||
<svg className={classes} width="16" height="12" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14 2l-8 8-4-4"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
} else if (size === SIZE_BIG) {
|
||||
return (
|
||||
<svg className={classes} strokeWidth="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.6 1.2c-.4-.3-1-.2-1.3.2L7.8 19l-5.2-5c-.4-.4-1-.4-1.3 0-.4.3-.4.8 0 1l6 5.6.6.2s.2 0 .4-.4l14.3-18c.3-.5.2-1-.2-1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
IconCheckmark.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
size: 'big',
|
||||
};
|
||||
|
||||
const { string } = PropTypes;
|
||||
|
|
@ -24,6 +43,7 @@ const { string } = PropTypes;
|
|||
IconCheckmark.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
size: string,
|
||||
};
|
||||
|
||||
export default IconCheckmark;
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@
|
|||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
/* When side nav is shown as a column, Topbar's box-shadow should be visible */
|
||||
z-index: calc(var(--zIndexTopbar) - 1);
|
||||
|
||||
@media (--viewportLarge) {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
/* When side nav is shown as a column, Topbar's box-shadow should be visible */
|
||||
z-index: calc(var(--zIndexTopbar) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import classNames from 'classnames';
|
|||
import {
|
||||
LayoutWrapperTopbar,
|
||||
LayoutWrapperSideNav,
|
||||
LayoutWrapperAccountSettingsSideNav,
|
||||
LayoutWrapperMain,
|
||||
LayoutWrapperFooter,
|
||||
} from '../../components';
|
||||
|
|
@ -31,7 +32,7 @@ const prepareChildren = children => {
|
|||
React.Children.forEach(children, child => {
|
||||
if (child.type === LayoutWrapperTopbar) {
|
||||
childrenMap.layoutWrapperTopbar = child;
|
||||
} else if (child.type === LayoutWrapperSideNav) {
|
||||
} else if ([LayoutWrapperSideNav, LayoutWrapperAccountSettingsSideNav].includes(child.type)) {
|
||||
childrenMap.layoutWrapperSideNav = child;
|
||||
} else if (child.type === LayoutWrapperMain) {
|
||||
// LayoutWrapperMain needs different rootClassName
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* This is a wrapper component for different Layouts.
|
||||
* Navigational 'aside' content should be added to this wrapper.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { node, number, string, shape } from 'prop-types';
|
||||
import { compose } from 'redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { withViewport } from '../../util/contextHelpers';
|
||||
import { LayoutWrapperSideNav } from '../../components';
|
||||
|
||||
const MAX_HORIZONTAL_NAV_SCREEN_WIDTH = 1023;
|
||||
|
||||
const scrollToTab = currentTab => {
|
||||
const el = document.querySelector(`#${currentTab}Tab`);
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
block: 'end',
|
||||
inline: 'end',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const LayoutWrapperAccountSettingsSideNavComponent = props => {
|
||||
const { currentTab, viewport } = props;
|
||||
|
||||
let hasScrolledToTab = false;
|
||||
|
||||
const { width } = viewport;
|
||||
const hasViewport = width > 0;
|
||||
const hasHorizontalTabLayout = hasViewport && width <= MAX_HORIZONTAL_NAV_SCREEN_WIDTH;
|
||||
const hasVerticalTabLayout = hasViewport && width > MAX_HORIZONTAL_NAV_SCREEN_WIDTH;
|
||||
const hasFontsLoaded = hasViewport && document.documentElement.classList.contains('fontsLoaded');
|
||||
|
||||
// Check if scrollToTab call is needed (tab is not visible on mobile)
|
||||
if (hasVerticalTabLayout) {
|
||||
hasScrolledToTab = true;
|
||||
} else if (hasHorizontalTabLayout && !hasScrolledToTab && hasFontsLoaded) {
|
||||
scrollToTab(currentTab);
|
||||
hasScrolledToTab = true;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
text: <FormattedMessage id="LayoutWrapperAccountSettingsSideNav.contactDetailsTabTitle" />,
|
||||
selected: currentTab === 'ContactDetailsPage',
|
||||
id: 'ContactDetailsPageTab',
|
||||
linkProps: {
|
||||
name: 'ContactDetailsPage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="LayoutWrapperAccountSettingsSideNav.passwordTabTitle" />,
|
||||
selected: currentTab === 'PasswordChangePage',
|
||||
id: 'PasswordChangePageTab',
|
||||
linkProps: {
|
||||
name: 'PasswordChangePage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="LayoutWrapperAccountSettingsSideNav.paymentsTabTitle" />,
|
||||
selected: currentTab === 'PayoutPreferencesPage',
|
||||
id: 'PayoutPreferencesPageTab',
|
||||
linkProps: {
|
||||
name: 'PayoutPreferencesPage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="LayoutWrapperAccountSettingsSideNav.paymentMethodsTabTitle" />,
|
||||
selected: currentTab === 'PaymentMethodsPage',
|
||||
id: 'PaymentMethodsPageTab',
|
||||
linkProps: {
|
||||
name: 'PaymentMethodsPage',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return <LayoutWrapperSideNav tabs={tabs} />;
|
||||
};
|
||||
|
||||
LayoutWrapperAccountSettingsSideNavComponent.defaultProps = {
|
||||
className: null,
|
||||
rootClassName: null,
|
||||
children: null,
|
||||
currentTab: null,
|
||||
};
|
||||
|
||||
LayoutWrapperAccountSettingsSideNavComponent.propTypes = {
|
||||
children: node,
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
currentTab: string,
|
||||
|
||||
// from withViewport
|
||||
viewport: shape({
|
||||
width: number.isRequired,
|
||||
height: number.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
const LayoutWrapperAccountSettingsSideNav = compose(withViewport)(
|
||||
LayoutWrapperAccountSettingsSideNavComponent
|
||||
);
|
||||
|
||||
export default LayoutWrapperAccountSettingsSideNav;
|
||||
236
src/components/SavedCardDetails/SavedCardDetails.css
Normal file
236
src/components/SavedCardDetails/SavedCardDetails.css
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
}
|
||||
|
||||
.savedPaymentMethodTitle {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.savedPaymentMethod {
|
||||
display: inline-block;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.paymentMethodPlaceholderDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.paymentMethodPlaceholderMobile {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (--viewportSmall) {
|
||||
.paymentMethodPlaceholderDesktop {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.paymentMethodPlaceholderMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
margin: 0 12px 0 4px;
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
@apply --marketplaceModalCloseIcon;
|
||||
margin-left: 0;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.iconCheckmark {
|
||||
stroke: var(--successColor);
|
||||
display: inline-block;
|
||||
margin: 12px 0 0 24px;
|
||||
}
|
||||
|
||||
.iconCheckmarkHidden {
|
||||
visibility: hidden;
|
||||
display: inline-block;
|
||||
margin: 0 0 0 24px;
|
||||
}
|
||||
|
||||
.expirationDate {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.expirationDateExpired {
|
||||
padding-left: 24px;
|
||||
color: var(--failColor);
|
||||
}
|
||||
|
||||
.cardExpiredText {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
color: var(--failColor);
|
||||
font-weight: var(--fontWeightSemiBold);
|
||||
margin-top: 13px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.menuLabel {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--matterColorNegative);
|
||||
transition: all 0.2s ease-out;
|
||||
z-index: calc(var(--zIndexPopup) + 1);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--matterColorLight);
|
||||
border: 1px solid var(--matterColorAnti);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: var(--matterColorLight);
|
||||
border: 1px solid var(--matterColorNegative);
|
||||
text-decoration: none;
|
||||
border-radius: 4px 4px 0px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.menuLabelWrapper,
|
||||
.menuLabelWrapperExpired {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
padding: 12px 24px 18px 16px;
|
||||
}
|
||||
|
||||
.menuLabelWrapperExpired {
|
||||
border: 1px solid var(--failColor);
|
||||
}
|
||||
|
||||
.menuContent {
|
||||
position: absolute;
|
||||
z-index: var(--zIndexPopup);
|
||||
|
||||
background-color: var(--matterColorLight);
|
||||
box-shadow: var(--boxShadowFilterButton);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--matterColorNegative);
|
||||
|
||||
transition: all 0.15s ease-out;
|
||||
|
||||
/* When the dropdown is open, we need to hack the box-shadow's position
|
||||
to fit the whole dropdown menu component with padding-top
|
||||
|
||||
MenuLabel height 56px + original padding-top 7px results to 63px
|
||||
*/
|
||||
padding: 63px 0 15px 0;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
color: var(--matterColor);
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.menuText,
|
||||
.menuTextReplaceCard {
|
||||
@apply --marketplaceMessageFontStyles;
|
||||
padding: 12px 8px;
|
||||
color: var(--matterColor);
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
width: 80%;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.menuTextReplaceCard svg {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
width: 22px;
|
||||
margin: 0 10px 0 4px;
|
||||
}
|
||||
|
||||
.menuDivider {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
color: var(--matterColorAnti);
|
||||
padding: 14px 24px 2px 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.iconArrow {
|
||||
stroke: var(--matterColor);
|
||||
fill: var(--matterColor);
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.IconArrowAnimation {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.savedPaymentMethodDelete {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
color: var(--matterColorAnti);
|
||||
cursor: pointer;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:hover {
|
||||
color: var(--matterColor);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
color: var(--matterColor);
|
||||
}
|
||||
}
|
||||
|
||||
.modalButtonsWrapper {
|
||||
padding-top: 80px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
/* Title of the modal */
|
||||
.modalTitle {
|
||||
@apply --marketplaceModalTitleStyles;
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
/* Paragraph for the Modal */
|
||||
.modalMessage {
|
||||
@apply --marketplaceModalParagraphStyles;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.cancelCardDelete {
|
||||
color: var(--matterColorAnti);
|
||||
cursor: pointer;
|
||||
width: 50%;
|
||||
text-align: right;
|
||||
padding-right: 64px;
|
||||
|
||||
&:hover {
|
||||
color: var(--matterColor);
|
||||
}
|
||||
}
|
||||
53
src/components/SavedCardDetails/SavedCardDetails.example.js
Normal file
53
src/components/SavedCardDetails/SavedCardDetails.example.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { fakeIntl } from '../../util/test-data';
|
||||
import SavedCardDetails from './SavedCardDetails';
|
||||
|
||||
const noop = () => null;
|
||||
const defaultProps = {
|
||||
intl: fakeIntl,
|
||||
onDeleteCard: noop,
|
||||
onChange: noop,
|
||||
onManageDisableScrolling: noop,
|
||||
};
|
||||
|
||||
export const SavedCardDetailsExample = {
|
||||
component: SavedCardDetails,
|
||||
props: {
|
||||
...defaultProps,
|
||||
card: {
|
||||
brand: 'visa',
|
||||
expirationMonth: 10,
|
||||
expirationYear: 2050,
|
||||
last4Digits: '3220',
|
||||
},
|
||||
},
|
||||
group: 'payment methods',
|
||||
};
|
||||
|
||||
export const SavedCardDetailsNoDelete = {
|
||||
component: SavedCardDetails,
|
||||
props: {
|
||||
...defaultProps,
|
||||
card: {
|
||||
brand: 'mastercard',
|
||||
expirationMonth: 10,
|
||||
expirationYear: 2050,
|
||||
last4Digits: '3220',
|
||||
},
|
||||
onDeleteCard: null,
|
||||
},
|
||||
group: 'payment methods',
|
||||
};
|
||||
|
||||
export const SavedCardDetailsExpired = {
|
||||
component: SavedCardDetails,
|
||||
props: {
|
||||
...defaultProps,
|
||||
card: {
|
||||
brand: 'amex',
|
||||
expirationMonth: 7,
|
||||
expirationYear: 2019,
|
||||
last4Digits: '3220',
|
||||
},
|
||||
},
|
||||
group: 'payment methods',
|
||||
};
|
||||
284
src/components/SavedCardDetails/SavedCardDetails.js
Normal file
284
src/components/SavedCardDetails/SavedCardDetails.js
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { bool, func, number, shape, string } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
import {
|
||||
IconArrowHead,
|
||||
IconCard,
|
||||
IconClose,
|
||||
IconCheckmark,
|
||||
Button,
|
||||
InlineTextButton,
|
||||
Menu,
|
||||
MenuLabel,
|
||||
MenuItem,
|
||||
MenuContent,
|
||||
Modal,
|
||||
} from '../../components';
|
||||
import css from './SavedCardDetails.css';
|
||||
|
||||
const DEFAULT_CARD = 'defaultCard';
|
||||
const REPLACE_CARD = 'replaceCard';
|
||||
|
||||
// TODO: change all the modals to use portals at some point.
|
||||
// Portal is used here to circumvent the problems that rise
|
||||
// from different levels of z-indexes in DOM tree. In this case
|
||||
// either TopBar or Menu element were overlapping the modal due
|
||||
// to stacking context. All the modals should be made with portals
|
||||
// but portals didn't exist when we originally created modals.
|
||||
|
||||
class Portal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.el = document.createElement('div');
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// The portal element is inserted in the DOM tree after
|
||||
// the Modal's children are mounted, meaning that children
|
||||
// will be mounted on a detached DOM node. If a child
|
||||
// component requires to be attached to the DOM tree
|
||||
// immediately when mounted, for example to measure a
|
||||
// DOM node, or uses 'autoFocus' in a descendant, add
|
||||
// state to Modal and only render the children when Modal
|
||||
// is inserted in the DOM tree.
|
||||
this.props.portalRoot.appendChild(this.el);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.portalRoot.removeChild(this.el);
|
||||
}
|
||||
|
||||
render() {
|
||||
return ReactDOM.createPortal(this.props.children, this.el);
|
||||
}
|
||||
}
|
||||
|
||||
const SavedCardDetails = props => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [active, setActive] = useState(DEFAULT_CARD);
|
||||
const [portalRoot, setPortalRoot] = useState(null);
|
||||
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
intl,
|
||||
card,
|
||||
onChange,
|
||||
onDeleteCard,
|
||||
onManageDisableScrolling,
|
||||
deletePaymentMethodInProgress,
|
||||
} = props;
|
||||
|
||||
const { last4Digits, expirationMonth, expirationYear, brand } = card || {};
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
|
||||
const paymentMethodPlaceholderDesktop = intl.formatMessage(
|
||||
{ id: 'SavedCardDetails.savedPaymentMethodPlaceholderDesktop' },
|
||||
{ lastFour: last4Digits }
|
||||
);
|
||||
|
||||
const paymentMethodPlaceholderMobile = intl.formatMessage(
|
||||
{ id: 'SavedCardDetails.savedPaymentMethodPlaceholderMobile' },
|
||||
{ lastFour: last4Digits }
|
||||
);
|
||||
|
||||
const paymentMethodPlaceholder = (
|
||||
<>
|
||||
<span className={css.paymentMethodPlaceholderDesktop}>{paymentMethodPlaceholderDesktop}</span>
|
||||
<span className={css.paymentMethodPlaceholderMobile}>{paymentMethodPlaceholderMobile}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const replaceCardText = intl.formatMessage({
|
||||
id: 'SavedCardDetails.replaceCardText',
|
||||
});
|
||||
const replaceCard = (
|
||||
<span>
|
||||
<IconCard brand="none" className={css.cardIcon} /> {replaceCardText}
|
||||
</span>
|
||||
);
|
||||
|
||||
const expiredCardText = intl.formatMessage(
|
||||
{ id: 'SavedCardDetails.expiredCardText' },
|
||||
{ lastFour: last4Digits }
|
||||
);
|
||||
const expiredText = <div className={css.cardExpiredText}>{expiredCardText}</div>;
|
||||
|
||||
const isExpired = (expirationMonth, expirationYear) => {
|
||||
const currentTime = new Date();
|
||||
const currentYear = currentTime.getFullYear();
|
||||
const currentMonth = currentTime.getMonth() + 1; //getMonth() method returns the month (from 0 to 11)
|
||||
|
||||
if (expirationYear < currentYear) {
|
||||
return true;
|
||||
} else if (expirationYear === currentYear && expirationMonth < currentMonth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isCardExpired =
|
||||
expirationMonth && expirationYear && isExpired(expirationMonth, expirationYear);
|
||||
|
||||
const defaultCard = (
|
||||
<div className={css.savedPaymentMethod}>
|
||||
<IconCard brand={brand} className={css.cardIcon} />
|
||||
{paymentMethodPlaceholder}
|
||||
<span className={isCardExpired ? css.expirationDateExpired : css.expirationDate}>
|
||||
{expirationMonth}/{expirationYear.toString().substring(2)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleClick = item => e => {
|
||||
// Clicking buttons inside a form will call submit
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setActive(item);
|
||||
setMenuOpen(false);
|
||||
if (onChange) {
|
||||
onChange(item);
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleActive = isOpen => {
|
||||
setMenuOpen(isOpen);
|
||||
};
|
||||
|
||||
const handleDeleteCard = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const iconArrowClassName = menuOpen ? css.IconArrowAnimation : null;
|
||||
|
||||
const replaceCardTitle = intl.formatMessage({
|
||||
id: 'SavedCardDetails.replaceCardTitle',
|
||||
});
|
||||
const removeCardModalTitle = intl.formatMessage({ id: 'SavedCardDetails.removeCardModalTitle' });
|
||||
const removeCardModalContent = intl.formatMessage({
|
||||
id: 'SavedCardDetails.removeCardModalContent',
|
||||
});
|
||||
const cancel = intl.formatMessage({ id: 'SavedCardDetails.cancel' });
|
||||
const removeCard = intl.formatMessage({ id: 'SavedCardDetails.removeCard' });
|
||||
const deletePaymentMethod = intl.formatMessage({ id: 'SavedCardDetails.deletePaymentMethod' });
|
||||
|
||||
const showExpired = isCardExpired && active === DEFAULT_CARD;
|
||||
|
||||
const setPortalRootAfterInitialRender = () => {
|
||||
setPortalRoot(document.getElementById('portal-root'));
|
||||
};
|
||||
return (
|
||||
<div className={classes} ref={setPortalRootAfterInitialRender}>
|
||||
<Menu className={css.menu} isOpen={menuOpen} onToggleActive={onToggleActive} useArrow={false}>
|
||||
<MenuLabel className={css.menuLabel}>
|
||||
<div className={showExpired ? css.menuLabelWrapperExpired : css.menuLabelWrapper}>
|
||||
{active === DEFAULT_CARD ? defaultCard : replaceCard}
|
||||
<span>
|
||||
<IconArrowHead
|
||||
direction="down"
|
||||
size="small"
|
||||
rootClassName={css.iconArrow}
|
||||
className={iconArrowClassName}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</MenuLabel>
|
||||
|
||||
<MenuContent rootClassName={css.menuContent}>
|
||||
<MenuItem key="first item" className={css.menuItem}>
|
||||
<IconCheckmark
|
||||
className={active === DEFAULT_CARD ? css.iconCheckmark : css.iconCheckmarkHidden}
|
||||
size="small"
|
||||
/>
|
||||
<InlineTextButton className={css.menuText} onClick={handleClick(DEFAULT_CARD)}>
|
||||
{defaultCard}
|
||||
</InlineTextButton>
|
||||
</MenuItem>
|
||||
<MenuItem key="divider" className={css.menuDivider}>
|
||||
{replaceCardTitle}
|
||||
</MenuItem>
|
||||
<MenuItem key="second item" className={css.menuItem}>
|
||||
<IconCheckmark
|
||||
className={active === REPLACE_CARD ? css.iconCheckmark : css.iconCheckmarkHidden}
|
||||
size="small"
|
||||
/>
|
||||
<InlineTextButton
|
||||
className={css.menuTextReplaceCard}
|
||||
onClick={handleClick(REPLACE_CARD)}
|
||||
>
|
||||
{replaceCard}
|
||||
</InlineTextButton>
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
{showExpired && !menuOpen ? expiredText : null}
|
||||
|
||||
{onDeleteCard && active !== REPLACE_CARD ? (
|
||||
<InlineTextButton onClick={handleDeleteCard} className={css.savedPaymentMethodDelete}>
|
||||
<IconClose rootClassName={css.closeIcon} size="small" />
|
||||
{deletePaymentMethod}
|
||||
</InlineTextButton>
|
||||
) : null}
|
||||
|
||||
{portalRoot && onManageDisableScrolling ? (
|
||||
<Portal portalRoot={portalRoot}>
|
||||
<Modal
|
||||
id="VerifyDeletingPaymentMethod"
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
contentClassName={css.modalContent}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
>
|
||||
<div>
|
||||
<div className={css.modalTitle}>{removeCardModalTitle}</div>
|
||||
<p className={css.modalMessage}>{removeCardModalContent}</p>
|
||||
<div className={css.modalButtonsWrapper}>
|
||||
<div onClick={() => setIsModalOpen(false)} className={css.cancelCardDelete}>
|
||||
{cancel}
|
||||
</div>
|
||||
<Button onClick={onDeleteCard} inProgress={deletePaymentMethodInProgress}>
|
||||
{removeCard}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SavedCardDetails.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
card: null,
|
||||
onChange: null,
|
||||
onDeleteCard: null,
|
||||
deletePaymentMethodInProgress: false,
|
||||
onManageDisableScrolling: null,
|
||||
};
|
||||
|
||||
SavedCardDetails.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
intl: intlShape.isRequired,
|
||||
card: shape({
|
||||
brand: string.isRequired,
|
||||
expirationMonth: number.isRequired,
|
||||
expirationYear: number.isRequired,
|
||||
last4Digits: string.isRequired,
|
||||
}),
|
||||
onChange: func,
|
||||
onDeleteCard: func,
|
||||
onManageDisableScrolling: func,
|
||||
deletePaymentMethodInProgress: bool,
|
||||
};
|
||||
|
||||
export default injectIntl(SavedCardDetails);
|
||||
35
src/components/StripePaymentAddress/StripePaymentAddress.css
Normal file
35
src/components/StripePaymentAddress/StripePaymentAddress.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.paymentAddressField {
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
/* This container uses flexbox layout */
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
/* parent aka root is flexbox, this container takes all available space */
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (--viewportSmall) {
|
||||
flex-wrap: no-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
|
||||
@media (--viewportSmall) {
|
||||
width: calc(50% - 12px);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import * as validators from '../../util/validators';
|
|||
import getCountryCodes from '../../translations/countryCodes';
|
||||
import { FieldTextInput, FieldSelect } from '../../components';
|
||||
|
||||
import css from './StripePaymentForm.css';
|
||||
import css from './StripePaymentAddress.css';
|
||||
|
||||
const StripePaymentAddress = props => {
|
||||
const { className, intl, disabled, form, fieldId, card } = props;
|
||||
|
|
@ -78,38 +78,39 @@ const StripePaymentAddress = props => {
|
|||
const countryCodes = getCountryCodes(config.locale);
|
||||
|
||||
return (
|
||||
<div className={className ? className : css.sectionContainer}>
|
||||
<FieldTextInput
|
||||
id={`${fieldId}.addressLine1`}
|
||||
name="addressLine1"
|
||||
disabled={disabled}
|
||||
className={css.field}
|
||||
type="text"
|
||||
autoComplete="billing address-line1"
|
||||
label={addressLine1Label}
|
||||
placeholder={addressLine1Placeholder}
|
||||
validate={addressLine1Required}
|
||||
onUnmount={() => form.change('addressLine1', undefined)}
|
||||
/>
|
||||
|
||||
<FieldTextInput
|
||||
id={`${fieldId}.addressLine2`}
|
||||
name="addressLine2"
|
||||
disabled={disabled}
|
||||
className={css.field}
|
||||
type="text"
|
||||
autoComplete="billing address-line2"
|
||||
label={addressLine2Label}
|
||||
placeholder={addressLine2Placeholder}
|
||||
onUnmount={() => form.change('addressLine2', undefined)}
|
||||
/>
|
||||
<div className={className ? className : css.root}>
|
||||
<div className={css.formRow}>
|
||||
<FieldTextInput
|
||||
id={`${fieldId}.addressLine1`}
|
||||
name="addressLine1"
|
||||
disabled={disabled}
|
||||
className={css.field}
|
||||
type="text"
|
||||
autoComplete="billing address-line1"
|
||||
label={addressLine1Label}
|
||||
placeholder={addressLine1Placeholder}
|
||||
validate={addressLine1Required}
|
||||
onUnmount={() => form.change('addressLine1', undefined)}
|
||||
/>
|
||||
|
||||
<FieldTextInput
|
||||
id={`${fieldId}.addressLine2`}
|
||||
name="addressLine2"
|
||||
disabled={disabled}
|
||||
className={css.field}
|
||||
type="text"
|
||||
autoComplete="billing address-line2"
|
||||
label={addressLine2Label}
|
||||
placeholder={addressLine2Placeholder}
|
||||
onUnmount={() => form.change('addressLine2', undefined)}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.formRow}>
|
||||
<FieldTextInput
|
||||
id={`${fieldId}.postalCode`}
|
||||
name="postal"
|
||||
disabled={disabled}
|
||||
className={css.postalCode}
|
||||
className={css.field}
|
||||
type="text"
|
||||
autoComplete="billing postal-code"
|
||||
label={postalCodeLabel}
|
||||
|
|
@ -123,7 +124,7 @@ const StripePaymentAddress = props => {
|
|||
id={`${fieldId}.city`}
|
||||
name="city"
|
||||
disabled={disabled}
|
||||
className={css.city}
|
||||
className={css.field}
|
||||
type="text"
|
||||
autoComplete="billing address-level2"
|
||||
label={cityLabel}
|
||||
|
|
@ -132,38 +133,39 @@ const StripePaymentAddress = props => {
|
|||
onUnmount={() => form.change('city', undefined)}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.formRow}>
|
||||
<FieldTextInput
|
||||
id={`${fieldId}.state`}
|
||||
name="state"
|
||||
disabled={disabled}
|
||||
className={css.field}
|
||||
type="text"
|
||||
autoComplete="billing address-level1"
|
||||
label={stateLabel}
|
||||
placeholder={statePlaceholder}
|
||||
onUnmount={() => form.change('state', undefined)}
|
||||
/>
|
||||
|
||||
<FieldTextInput
|
||||
id={`${fieldId}.state`}
|
||||
name="state"
|
||||
disabled={disabled}
|
||||
className={css.field}
|
||||
type="text"
|
||||
autoComplete="billing address-level1"
|
||||
label={stateLabel}
|
||||
placeholder={statePlaceholder}
|
||||
onUnmount={() => form.change('state', undefined)}
|
||||
/>
|
||||
|
||||
<FieldSelect
|
||||
id={`${fieldId}.country`}
|
||||
name="country"
|
||||
disabled={disabled}
|
||||
className={css.field}
|
||||
label={countryLabel}
|
||||
validate={countryRequired}
|
||||
>
|
||||
<option disabled value="">
|
||||
{countryPlaceholder}
|
||||
</option>
|
||||
{countryCodes.map(country => {
|
||||
return (
|
||||
<option key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
id={`${fieldId}.country`}
|
||||
name="country"
|
||||
disabled={disabled}
|
||||
className={css.field}
|
||||
label={countryLabel}
|
||||
validate={countryRequired}
|
||||
>
|
||||
<option disabled value="">
|
||||
{countryPlaceholder}
|
||||
</option>
|
||||
{countryCodes.map(country => {
|
||||
return (
|
||||
<option key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
.link {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Font */
|
||||
@apply --marketplaceTabNavFontStyles;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
.genericError {
|
||||
color: var(--failColor);
|
||||
margin: 24px 24px 0 24px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 32px 24px 0 24px;
|
||||
}
|
||||
@media (--viewportLarge) {
|
||||
margin: 32px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* DetailCardImage subcomponent */
|
||||
.detailCardImageWrapper {
|
||||
/* Layout */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { Component } from 'react';
|
||||
import { array, arrayOf, bool, func, number, string } from 'prop-types';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY,
|
||||
|
|
@ -22,7 +22,13 @@ import {
|
|||
} from '../../util/data';
|
||||
import { isMobileSafari } from '../../util/userAgent';
|
||||
import { formatMoney } from '../../util/currency';
|
||||
import { AvatarLarge, BookingPanel, ReviewModal, UserDisplayName } from '../../components';
|
||||
import {
|
||||
AvatarLarge,
|
||||
BookingPanel,
|
||||
NamedLink,
|
||||
ReviewModal,
|
||||
UserDisplayName,
|
||||
} from '../../components';
|
||||
import { SendMessageForm } from '../../forms';
|
||||
import config from '../../config';
|
||||
|
||||
|
|
@ -164,6 +170,7 @@ export class TransactionPanelComponent extends Component {
|
|||
oldestMessagePageFetched,
|
||||
messages,
|
||||
initialMessageFailed,
|
||||
savePaymentMethodFailed,
|
||||
fetchMessagesInProgress,
|
||||
fetchMessagesError,
|
||||
sendMessageInProgress,
|
||||
|
|
@ -318,6 +325,12 @@ export class TransactionPanelComponent extends Component {
|
|||
id: 'TransactionPanel.sendingMessageNotAllowed',
|
||||
});
|
||||
|
||||
const paymentMethodsPageLink = (
|
||||
<NamedLink name="PaymentMethodsPage">
|
||||
<FormattedMessage id="TransactionPanel.paymentMethodsPageLink" />
|
||||
</NamedLink>
|
||||
);
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
|
||||
return (
|
||||
|
|
@ -359,6 +372,14 @@ export class TransactionPanelComponent extends Component {
|
|||
<BreakdownMaybe transaction={currentTransaction} transactionRole={transactionRole} />
|
||||
</div>
|
||||
|
||||
{savePaymentMethodFailed ? (
|
||||
<p className={css.genericError}>
|
||||
<FormattedMessage
|
||||
id="TransactionPanel.savePaymentMethodFailed"
|
||||
values={{ paymentMethodsPageLink }}
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
<FeedSection
|
||||
rootClassName={css.feedContainer}
|
||||
currentTransaction={currentTransaction}
|
||||
|
|
@ -460,7 +481,8 @@ TransactionPanelComponent.defaultProps = {
|
|||
acceptSaleError: null,
|
||||
declineSaleError: null,
|
||||
fetchMessagesError: null,
|
||||
initialMessageFailed: null,
|
||||
initialMessageFailed: false,
|
||||
savePaymentMethodFailed: false,
|
||||
sendMessageError: null,
|
||||
sendReviewError: null,
|
||||
timeSlots: null,
|
||||
|
|
@ -478,6 +500,7 @@ TransactionPanelComponent.propTypes = {
|
|||
oldestMessagePageFetched: number.isRequired,
|
||||
messages: arrayOf(propTypes.message).isRequired,
|
||||
initialMessageFailed: bool,
|
||||
savePaymentMethodFailed: bool,
|
||||
fetchMessagesInProgress: bool.isRequired,
|
||||
fetchMessagesError: propTypes.error,
|
||||
sendMessageInProgress: bool.isRequired,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
export { default as IconAdd } from './IconAdd/IconAdd';
|
||||
export { default as IconArrowHead } from './IconArrowHead/IconArrowHead';
|
||||
export { default as IconBannedUser } from './IconBannedUser/IconBannedUser';
|
||||
export { default as IconCard } from './IconCard/IconCard';
|
||||
export { default as IconCheckmark } from './IconCheckmark/IconCheckmark';
|
||||
export { default as IconClose } from './IconClose/IconClose';
|
||||
export { default as IconEmailAttention } from './IconEmailAttention/IconEmailAttention';
|
||||
|
|
@ -127,6 +128,7 @@ export { default as OrderDiscussionPanel } from './OrderDiscussionPanel/OrderDis
|
|||
export { default as Page } from './Page/Page';
|
||||
export { default as PriceFilter } from './PriceFilter/PriceFilter';
|
||||
export { default as Reviews } from './Reviews/Reviews';
|
||||
export { default as SavedCardDetails } from './SavedCardDetails/SavedCardDetails';
|
||||
export { default as SearchFilters } from './SearchFilters/SearchFilters';
|
||||
export { default as SearchFiltersMobile } from './SearchFiltersMobile/SearchFiltersMobile';
|
||||
export { default as SearchFiltersPanel } from './SearchFiltersPanel/SearchFiltersPanel';
|
||||
|
|
@ -137,12 +139,14 @@ export { default as SearchMapPriceLabel } from './SearchMapPriceLabel/SearchMapP
|
|||
export { default as SearchResultsPanel } from './SearchResultsPanel/SearchResultsPanel';
|
||||
export { default as SelectMultipleFilter } from './SelectMultipleFilter/SelectMultipleFilter';
|
||||
export { default as SelectSingleFilter } from './SelectSingleFilter/SelectSingleFilter';
|
||||
export { default as StripePaymentAddress } from './StripePaymentAddress/StripePaymentAddress';
|
||||
export { default as UserCard } from './UserCard/UserCard';
|
||||
|
||||
//////////////////////////////////////////////
|
||||
// Page sections and modal content wrappers //
|
||||
//////////////////////////////////////////////
|
||||
|
||||
export { default as LayoutWrapperAccountSettingsSideNav } from './LayoutWrapperAccountSettingsSideNav/LayoutWrapperAccountSettingsSideNav';
|
||||
export { default as ModalMissingInformation } from './ModalMissingInformation/ModalMissingInformation';
|
||||
export { default as ReviewModal } from './ReviewModal/ReviewModal';
|
||||
export { default as PrivacyPolicy } from './PrivacyPolicy/PrivacyPolicy';
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@
|
|||
--CheckoutPage_logoHeightDesktop: 27px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
/* Dummy Topbar */
|
||||
|
||||
.topbar {
|
||||
|
|
@ -201,7 +197,7 @@
|
|||
|
||||
.priceBreakdownContainer {
|
||||
padding: 0 24px;
|
||||
margin-bottom: 40px;
|
||||
margin-bottom: 37px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-bottom: 38px;
|
||||
|
|
@ -236,7 +232,12 @@
|
|||
padding: 0 24px;
|
||||
margin-bottom: 35px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (--viewportLarge) {
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
TRANSITION_CONFIRM_PAYMENT,
|
||||
} from '../../util/transaction';
|
||||
import * as log from '../../util/log';
|
||||
import { fetchCurrentUserHasOrdersSuccess } from '../../ducks/user.duck';
|
||||
import { fetchCurrentUserHasOrdersSuccess, fetchCurrentUser } from '../../ducks/user.duck';
|
||||
|
||||
// ================ Action types ================ //
|
||||
|
||||
|
|
@ -26,6 +26,10 @@ export const SPECULATE_TRANSACTION_REQUEST = 'app/ListingPage/SPECULATE_TRANSACT
|
|||
export const SPECULATE_TRANSACTION_SUCCESS = 'app/ListingPage/SPECULATE_TRANSACTION_SUCCESS';
|
||||
export const SPECULATE_TRANSACTION_ERROR = 'app/ListingPage/SPECULATE_TRANSACTION_ERROR';
|
||||
|
||||
export const STRIPE_CUSTOMER_REQUEST = 'app/CheckoutPage/STRIPE_CUSTOMER_REQUEST';
|
||||
export const STRIPE_CUSTOMER_SUCCESS = 'app/CheckoutPage/STRIPE_CUSTOMER_SUCCESS';
|
||||
export const STRIPE_CUSTOMER_ERROR = 'app/CheckoutPage/STRIPE_CUSTOMER_ERROR';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const initialState = {
|
||||
|
|
@ -38,6 +42,7 @@ const initialState = {
|
|||
transaction: null,
|
||||
initiateOrderError: null,
|
||||
confirmPaymentError: null,
|
||||
stripeCustomerFetched: false,
|
||||
};
|
||||
|
||||
export default function checkoutPageReducer(state = initialState, action = {}) {
|
||||
|
|
@ -83,6 +88,14 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
|
|||
console.error(payload); // eslint-disable-line no-console
|
||||
return { ...state, confirmPaymentError: payload };
|
||||
|
||||
case STRIPE_CUSTOMER_REQUEST:
|
||||
return { ...state, stripeCustomerFetched: false };
|
||||
case STRIPE_CUSTOMER_SUCCESS:
|
||||
return { ...state, stripeCustomerFetched: true };
|
||||
case STRIPE_CUSTOMER_ERROR:
|
||||
console.error(payload); // eslint-disable-line no-console
|
||||
return { ...state, stripeCustomerFetchError: payload };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -136,6 +149,14 @@ export const speculateTransactionError = e => ({
|
|||
payload: e,
|
||||
});
|
||||
|
||||
export const stripeCustomerRequest = () => ({ type: STRIPE_CUSTOMER_REQUEST });
|
||||
export const stripeCustomerSuccess = () => ({ type: STRIPE_CUSTOMER_SUCCESS });
|
||||
export const stripeCustomerError = e => ({
|
||||
type: STRIPE_CUSTOMER_ERROR,
|
||||
error: true,
|
||||
payload: e,
|
||||
});
|
||||
|
||||
/* ================ Thunks ================ */
|
||||
|
||||
export const initiateOrder = (orderParams, transactionId) => (dispatch, getState, sdk) => {
|
||||
|
|
@ -272,3 +293,17 @@ export const speculateTransaction = params => (dispatch, getState, sdk) => {
|
|||
return dispatch(speculateTransactionError(storableError(e)));
|
||||
});
|
||||
};
|
||||
|
||||
// StripeCustomer is a relantionship to currentUser
|
||||
// We need to fetch currentUser with correct params to include relationship
|
||||
export const stripeCustomer = () => (dispatch, getState, sdk) => {
|
||||
dispatch(stripeCustomerRequest());
|
||||
|
||||
return dispatch(fetchCurrentUser({ include: ['stripeCustomer.defaultPaymentMethod'] }))
|
||||
.then(response => {
|
||||
dispatch(stripeCustomerSuccess());
|
||||
})
|
||||
.catch(e => {
|
||||
dispatch(stripeCustomerError(storableError(e)));
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
ensureUser,
|
||||
ensureTransaction,
|
||||
ensureBooking,
|
||||
ensureStripeCustomer,
|
||||
ensurePaymentMethodCard,
|
||||
} from '../../util/data';
|
||||
import { dateFromLocalToAPI, minutesBetween } from '../../util/dates';
|
||||
import { createSlug } from '../../util/urlHelpers';
|
||||
|
|
@ -40,12 +42,14 @@ import {
|
|||
} from '../../components';
|
||||
import { StripePaymentForm } from '../../forms';
|
||||
import { isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { handleCardPayment, retrievePaymentIntent } from '../../ducks/stripe.duck.js';
|
||||
import { handleCardPayment, retrievePaymentIntent } from '../../ducks/stripe.duck';
|
||||
import { savePaymentMethod } from '../../ducks/paymentMethods.duck';
|
||||
|
||||
import {
|
||||
initiateOrder,
|
||||
setInitialValues,
|
||||
speculateTransaction,
|
||||
stripeCustomer,
|
||||
confirmPayment,
|
||||
sendMessage,
|
||||
} from './CheckoutPage.duck';
|
||||
|
|
@ -58,6 +62,21 @@ const STORAGE_KEY = 'CheckoutPage';
|
|||
// https://stripe.com/docs/payments/payment-intents/status
|
||||
const STRIPE_PI_USER_ACTIONS_DONE_STATUSES = ['processing', 'requires_capture', 'succeeded'];
|
||||
|
||||
// Payment charge options
|
||||
const ONETIME_PAYMENT = 'ONETIME_PAYMENT';
|
||||
const PAY_AND_SAVE_FOR_LATER_USE = 'PAY_AND_SAVE_FOR_LATER_USE';
|
||||
const USE_SAVED_CARD = 'USE_SAVED_CARD';
|
||||
|
||||
const paymentFlow = (selectedPaymentMethod, saveAfterOnetimePayment) => {
|
||||
// Payment mode could be 'replaceCard', but without explicit saveAfterOnetimePayment flag,
|
||||
// we'll handle it as one-time payment
|
||||
return selectedPaymentMethod === 'defaultCard'
|
||||
? USE_SAVED_CARD
|
||||
: saveAfterOnetimePayment
|
||||
? PAY_AND_SAVE_FOR_LATER_USE
|
||||
: ONETIME_PAYMENT;
|
||||
};
|
||||
|
||||
const initializeOrderPage = (initialValues, routes, dispatch) => {
|
||||
const OrderPage = findRouteByRouteName('OrderDetailsPage', routes);
|
||||
|
||||
|
|
@ -120,8 +139,15 @@ export class CheckoutPageComponent extends Component {
|
|||
listing,
|
||||
transaction,
|
||||
fetchSpeculatedTransaction,
|
||||
fetchStripeCustomer,
|
||||
history,
|
||||
} = this.props;
|
||||
|
||||
// Fetch currentUser with stripeCustomer entity
|
||||
// Note: since there's need for data loading in "componentWillMount" function,
|
||||
// this is added here instead of loadData static function.
|
||||
fetchStripeCustomer();
|
||||
|
||||
// Browser's back navigation should not rewrite data in session store.
|
||||
// Action is 'POP' on both history.back() and page refresh cases.
|
||||
// Action is 'PUSH' when user has directed through a link
|
||||
|
|
@ -176,10 +202,44 @@ export class CheckoutPageComponent extends Component {
|
|||
}
|
||||
|
||||
handlePaymentIntent(handlePaymentParams) {
|
||||
const { onInitiateOrder, onHandleCardPayment, onConfirmPayment, onSendMessage } = this.props;
|
||||
const { pageData, speculatedTransaction, message } = handlePaymentParams;
|
||||
const {
|
||||
currentUser,
|
||||
stripeCustomerFetched,
|
||||
onInitiateOrder,
|
||||
onHandleCardPayment,
|
||||
onConfirmPayment,
|
||||
onSendMessage,
|
||||
onSavePaymentMethod,
|
||||
} = this.props;
|
||||
const {
|
||||
pageData,
|
||||
speculatedTransaction,
|
||||
message,
|
||||
paymentIntent,
|
||||
selectedPaymentMethod,
|
||||
saveAfterOnetimePayment,
|
||||
} = handlePaymentParams;
|
||||
const storedTx = ensureTransaction(pageData.transaction);
|
||||
|
||||
const ensuredCurrentUser = ensureCurrentUser(currentUser);
|
||||
const ensuredStripeCustomer = ensureStripeCustomer(ensuredCurrentUser.stripeCustomer);
|
||||
const ensuredDefaultPaymentMethod = ensurePaymentMethodCard(
|
||||
ensuredStripeCustomer.defaultPaymentMethod
|
||||
);
|
||||
|
||||
let createdPaymentIntent = null;
|
||||
|
||||
const hasDefaultPaymentMethod = !!(
|
||||
stripeCustomerFetched &&
|
||||
ensuredStripeCustomer.attributes.stripeCustomerId &&
|
||||
ensuredDefaultPaymentMethod.id
|
||||
);
|
||||
const stripePaymentMethodId = hasDefaultPaymentMethod
|
||||
? ensuredDefaultPaymentMethod.attributes.stripePaymentMethodId
|
||||
: null;
|
||||
|
||||
const selectedPaymentFlow = paymentFlow(selectedPaymentMethod, saveAfterOnetimePayment);
|
||||
|
||||
// Step 1: initiate order by requesting payment from Marketplace API
|
||||
const fnRequestPayment = fnParams => {
|
||||
// fnParams should be { listingId, bookingStart, bookingEnd }
|
||||
|
|
@ -216,17 +276,26 @@ export class CheckoutPageComponent extends Component {
|
|||
: null;
|
||||
|
||||
const { stripe, card, billingDetails, paymentIntent } = handlePaymentParams;
|
||||
const stripeElementMaybe = selectedPaymentFlow !== USE_SAVED_CARD ? { card } : {};
|
||||
|
||||
// Note: payment_method could be set here for USE_SAVED_CARD flow.
|
||||
// { payment_method: stripePaymentMethodId }
|
||||
// However, we have set it already on API side, when PaymentIntent was created.
|
||||
const paymentParams =
|
||||
selectedPaymentFlow !== USE_SAVED_CARD
|
||||
? {
|
||||
payment_method_data: {
|
||||
billing_details: billingDetails,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
const params = {
|
||||
stripePaymentIntentClientSecret,
|
||||
orderId: order.id,
|
||||
stripe,
|
||||
card,
|
||||
paymentParams: {
|
||||
payment_method_data: {
|
||||
billing_details: billingDetails,
|
||||
},
|
||||
},
|
||||
...stripeElementMaybe,
|
||||
paymentParams,
|
||||
};
|
||||
|
||||
// If paymentIntent status is not waiting user action,
|
||||
|
|
@ -240,13 +309,37 @@ export class CheckoutPageComponent extends Component {
|
|||
|
||||
// Step 3: complete order by confirming payment to Marketplace API
|
||||
// Parameter should contain { paymentIntent, transactionId } returned in step 2
|
||||
const fnConfirmPayment = onConfirmPayment;
|
||||
const fnConfirmPayment = fnParams => {
|
||||
createdPaymentIntent = fnParams.paymentIntent;
|
||||
return onConfirmPayment(fnParams);
|
||||
};
|
||||
|
||||
// Step 4: send initial message
|
||||
const fnSendMessage = fnParams => {
|
||||
return onSendMessage({ ...fnParams, message });
|
||||
};
|
||||
|
||||
// Step 5: optionally save card as defaultPaymentMethod
|
||||
const fnSavePaymentMethod = fnParams => {
|
||||
const pi = createdPaymentIntent || paymentIntent;
|
||||
|
||||
if (selectedPaymentFlow === PAY_AND_SAVE_FOR_LATER_USE) {
|
||||
return onSavePaymentMethod(ensuredStripeCustomer, pi.payment_method)
|
||||
.then(response => {
|
||||
if (response.errors) {
|
||||
return { ...fnParams, paymentMethodSaved: false };
|
||||
}
|
||||
return { ...fnParams, paymentMethodSaved: true };
|
||||
})
|
||||
.catch(e => {
|
||||
// Real error cases are catched already in paymentMethods page.
|
||||
return { ...fnParams, paymentMethodSaved: false };
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve({ ...fnParams, paymentMethodSaved: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Here we create promise calls in sequence
|
||||
// This is pretty much the same as:
|
||||
// fnRequestPayment({...initialParams})
|
||||
|
|
@ -258,17 +351,30 @@ export class CheckoutPageComponent extends Component {
|
|||
fnRequestPayment,
|
||||
fnHandleCardPayment,
|
||||
fnConfirmPayment,
|
||||
fnSendMessage
|
||||
fnSendMessage,
|
||||
fnSavePaymentMethod
|
||||
);
|
||||
|
||||
// Create order aka transaction
|
||||
// NOTE: if unit type is line-item/units, quantity needs to be added.
|
||||
// The way to pass it to checkout page is through pageData.bookingData
|
||||
const tx = speculatedTransaction ? speculatedTransaction : storedTx;
|
||||
|
||||
// Note: optionalPaymentParams contains Stripe paymentMethod,
|
||||
// but that can also be passed on Step 2
|
||||
// stripe.handleCardPayment(stripe, { payment_method: stripePaymentMethodId })
|
||||
const optionalPaymentParams =
|
||||
selectedPaymentFlow === USE_SAVED_CARD && hasDefaultPaymentMethod
|
||||
? { paymentMethod: stripePaymentMethodId }
|
||||
: selectedPaymentFlow === PAY_AND_SAVE_FOR_LATER_USE
|
||||
? { setupPaymentMethodForSaving: true }
|
||||
: {};
|
||||
|
||||
const orderParams = {
|
||||
listingId: pageData.listing.id,
|
||||
bookingStart: tx.booking.attributes.start,
|
||||
bookingEnd: tx.booking.attributes.end,
|
||||
...optionalPaymentParams,
|
||||
};
|
||||
|
||||
return handlePaymentIntentCreation(orderParams);
|
||||
|
|
@ -281,8 +387,17 @@ export class CheckoutPageComponent extends Component {
|
|||
this.setState({ submitting: true });
|
||||
|
||||
const { history, speculatedTransaction, currentUser, paymentIntent, dispatch } = this.props;
|
||||
const { card, message, formValues } = values;
|
||||
const { name, addressLine1, addressLine2, postal, city, state, country } = formValues;
|
||||
const { card, message, paymentMethod, formValues } = values;
|
||||
const {
|
||||
name,
|
||||
addressLine1,
|
||||
addressLine2,
|
||||
postal,
|
||||
city,
|
||||
state,
|
||||
country,
|
||||
saveAfterOnetimePayment,
|
||||
} = formValues;
|
||||
|
||||
// Billing address is recommended.
|
||||
// However, let's not assume that <StripePaymentAddress> data is among formValues.
|
||||
|
|
@ -315,18 +430,24 @@ export class CheckoutPageComponent extends Component {
|
|||
billingDetails,
|
||||
message,
|
||||
paymentIntent,
|
||||
selectedPaymentMethod: paymentMethod,
|
||||
saveAfterOnetimePayment: !!saveAfterOnetimePayment,
|
||||
};
|
||||
|
||||
this.handlePaymentIntent(requestPaymentParams)
|
||||
.then(res => {
|
||||
const { orderId, messageSuccess } = res;
|
||||
const { orderId, messageSuccess, paymentMethodSaved } = res;
|
||||
this.setState({ submitting: false });
|
||||
|
||||
const routes = routeConfiguration();
|
||||
const initialMessageFailedToTransaction = messageSuccess ? null : orderId;
|
||||
const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, { id: orderId.uuid });
|
||||
const initialValues = {
|
||||
initialMessageFailedToTransaction,
|
||||
savePaymentMethodFailed: !paymentMethodSaved,
|
||||
};
|
||||
|
||||
initializeOrderPage({ initialMessageFailedToTransaction }, routes, dispatch);
|
||||
initializeOrderPage(initialValues, routes, dispatch);
|
||||
clearData(STORAGE_KEY);
|
||||
history.push(orderDetailsPath);
|
||||
})
|
||||
|
|
@ -378,6 +499,7 @@ export class CheckoutPageComponent extends Component {
|
|||
handleCardPaymentError,
|
||||
paymentIntent,
|
||||
retrievePaymentIntentError,
|
||||
stripeCustomerFetched,
|
||||
} = this.props;
|
||||
|
||||
// Since the listing data is already given from the ListingPage
|
||||
|
|
@ -443,6 +565,11 @@ export class CheckoutPageComponent extends Component {
|
|||
) : null;
|
||||
|
||||
const isPaymentExpired = checkIsPaymentExpired(existingTransaction);
|
||||
const hasDefaultPaymentMethod = !!(
|
||||
stripeCustomerFetched &&
|
||||
ensureStripeCustomer(currentUser.stripeCustomer).attributes.stripeCustomerId &&
|
||||
ensurePaymentMethodCard(currentUser.stripeCustomer.defaultPaymentMethod).id
|
||||
);
|
||||
|
||||
// Allow showing page when currentUser is still being downloaded,
|
||||
// but show payment form only when user info is loaded.
|
||||
|
|
@ -597,14 +724,7 @@ export class CheckoutPageComponent extends Component {
|
|||
const pageProps = { title, scrollingDisabled };
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Page {...pageProps}>
|
||||
{topbar}
|
||||
<div className={css.loading}>
|
||||
<FormattedMessage id="CheckoutPage.loadingData" />
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
return <Page {...pageProps}>{topbar}</Page>;
|
||||
}
|
||||
|
||||
// Get first and last name of the current user and use it in the StripePaymentForm to autofill the name field
|
||||
|
|
@ -683,6 +803,10 @@ export class CheckoutPageComponent extends Component {
|
|||
handleCardPaymentError={handleCardPaymentError}
|
||||
confirmPaymentError={confirmPaymentError}
|
||||
hasHandledCardPayment={hasPaymentIntentUserActionsDone}
|
||||
loadingData={!stripeCustomerFetched}
|
||||
defaultPaymentMethod={
|
||||
hasDefaultPaymentMethod ? currentUser.stripeCustomer.defaultPaymentMethod : null
|
||||
}
|
||||
paymentIntent={paymentIntent}
|
||||
onStripeInitialized={this.onStripeInitialized}
|
||||
/>
|
||||
|
|
@ -747,6 +871,8 @@ CheckoutPageComponent.propTypes = {
|
|||
bookingStart: instanceOf(Date).isRequired,
|
||||
bookingEnd: instanceOf(Date).isRequired,
|
||||
}),
|
||||
fetchStripeCustomer: func.isRequired,
|
||||
stripeCustomerFetched: bool.isRequired,
|
||||
fetchSpeculatedTransaction: func.isRequired,
|
||||
speculateTransactionInProgress: bool.isRequired,
|
||||
speculateTransactionError: propTypes.error,
|
||||
|
|
@ -757,9 +883,12 @@ CheckoutPageComponent.propTypes = {
|
|||
id: string,
|
||||
slug: string,
|
||||
}).isRequired,
|
||||
onConfirmPayment: func.isRequired,
|
||||
onInitiateOrder: func.isRequired,
|
||||
onHandleCardPayment: func.isRequired,
|
||||
onRetrievePaymentIntent: func.isRequired,
|
||||
onSavePaymentMethod: func.isRequired,
|
||||
onSendMessage: func.isRequired,
|
||||
initiateOrderError: propTypes.error,
|
||||
confirmPaymentError: propTypes.error,
|
||||
// handleCardPaymentError comes from Stripe so that's why we can't expect it to be in a specific form
|
||||
|
|
@ -783,6 +912,7 @@ const mapStateToProps = state => {
|
|||
listing,
|
||||
bookingData,
|
||||
bookingDates,
|
||||
stripeCustomerFetched,
|
||||
speculateTransactionInProgress,
|
||||
speculateTransactionError,
|
||||
speculatedTransaction,
|
||||
|
|
@ -795,6 +925,7 @@ const mapStateToProps = state => {
|
|||
return {
|
||||
scrollingDisabled: isScrollingDisabled(state),
|
||||
currentUser,
|
||||
stripeCustomerFetched,
|
||||
bookingData,
|
||||
bookingDates,
|
||||
speculateTransactionInProgress,
|
||||
|
|
@ -812,12 +943,15 @@ const mapStateToProps = state => {
|
|||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
dispatch,
|
||||
onInitiateOrder: (params, transactionId) => dispatch(initiateOrder(params, transactionId)),
|
||||
fetchSpeculatedTransaction: params => dispatch(speculateTransaction(params)),
|
||||
fetchStripeCustomer: () => dispatch(stripeCustomer()),
|
||||
onInitiateOrder: (params, transactionId) => dispatch(initiateOrder(params, transactionId)),
|
||||
onRetrievePaymentIntent: params => dispatch(retrievePaymentIntent(params)),
|
||||
onHandleCardPayment: params => dispatch(handleCardPayment(params)),
|
||||
onConfirmPayment: params => dispatch(confirmPayment(params)),
|
||||
onSendMessage: params => dispatch(sendMessage(params)),
|
||||
onSavePaymentMethod: (stripeCustomer, stripePaymentMethodId) =>
|
||||
dispatch(savePaymentMethod(stripeCustomer, stripePaymentMethodId)),
|
||||
});
|
||||
|
||||
const CheckoutPage = compose(
|
||||
|
|
|
|||
|
|
@ -21,12 +21,17 @@ describe('CheckoutPage', () => {
|
|||
currentUser: createCurrentUser('currentUser'),
|
||||
params: { id: 'listing1', slug: 'listing1' },
|
||||
sendOrderRequest: noop,
|
||||
fetchStripeCustomer: noop,
|
||||
stripeCustomerFetched: false,
|
||||
fetchSpeculatedTransaction: noop,
|
||||
speculateTransactionInProgress: false,
|
||||
scrollingDisabled: false,
|
||||
onConfirmPayment: noop,
|
||||
onHandleCardPayment: noop,
|
||||
onInitiateOrder: noop,
|
||||
onRetrievePaymentIntent: noop,
|
||||
onSavePaymentMethod: noop,
|
||||
onSendMessage: noop,
|
||||
handleCardPaymentInProgress: false,
|
||||
};
|
||||
const tree = renderShallow(<CheckoutPageComponent {...props} />);
|
||||
|
|
@ -60,6 +65,7 @@ describe('CheckoutPage', () => {
|
|||
listing: null,
|
||||
bookingData: null,
|
||||
bookingDates: null,
|
||||
stripeCustomerFetched: false,
|
||||
speculateTransactionError: null,
|
||||
speculateTransactionInProgress: false,
|
||||
speculatedTransaction: null,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ exports[`CheckoutPage matches snapshot 1`] = `
|
|||
<InjectIntl(StripePaymentForm)
|
||||
authorDisplayName="author display name"
|
||||
confirmPaymentError={null}
|
||||
defaultPaymentMethod={null}
|
||||
formId="CheckoutPagePaymentForm"
|
||||
hasHandledCardPayment={null}
|
||||
inProgress={false}
|
||||
|
|
@ -98,6 +99,7 @@ exports[`CheckoutPage matches snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
initiateOrderError={null}
|
||||
loadingData={true}
|
||||
onStripeInitialized={[Function]}
|
||||
onSubmit={[Function]}
|
||||
paymentInfo="CheckoutPage.paymentInfo"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { fetchCurrentUser, sendVerificationEmail } from '../../ducks/user.duck';
|
|||
import {
|
||||
LayoutSideNavigation,
|
||||
LayoutWrapperMain,
|
||||
LayoutWrapperSideNav,
|
||||
LayoutWrapperAccountSettingsSideNav,
|
||||
LayoutWrapperTopbar,
|
||||
LayoutWrapperFooter,
|
||||
Footer,
|
||||
|
|
@ -39,30 +39,6 @@ export const ContactDetailsPageComponent = props => {
|
|||
intl,
|
||||
} = props;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
text: <FormattedMessage id="ContactDetailsPage.contactDetailsTabTitle" />,
|
||||
selected: true,
|
||||
linkProps: {
|
||||
name: 'ContactDetailsPage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="ContactDetailsPage.passwordTabTitle" />,
|
||||
selected: false,
|
||||
linkProps: {
|
||||
name: 'PasswordChangePage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="ContactDetailsPage.paymentsTabTitle" />,
|
||||
selected: false,
|
||||
linkProps: {
|
||||
name: 'PayoutPreferencesPage',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const user = ensureCurrentUser(currentUser);
|
||||
const currentEmail = user.attributes.email || '';
|
||||
const protectedData = user.attributes.profile.protectedData || {};
|
||||
|
|
@ -97,7 +73,7 @@ export const ContactDetailsPageComponent = props => {
|
|||
/>
|
||||
<UserNav selectedPageName="ContactDetailsPage" />
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperSideNav tabs={tabs} />
|
||||
<LayoutWrapperAccountSettingsSideNav currentTab="ContactDetailsPage" />
|
||||
<LayoutWrapperMain>
|
||||
<div className={css.content}>
|
||||
<h1 className={css.title}>
|
||||
|
|
|
|||
|
|
@ -23,43 +23,8 @@ exports[`ContactDetailsPage matches snapshot 1`] = `
|
|||
selectedPageName="ContactDetailsPage"
|
||||
/>
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperSideNav
|
||||
className={null}
|
||||
rootClassName={null}
|
||||
tabs={
|
||||
Array [
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "ContactDetailsPage",
|
||||
},
|
||||
"selected": true,
|
||||
"text": <FormattedMessage
|
||||
id="ContactDetailsPage.contactDetailsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PasswordChangePage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="ContactDetailsPage.passwordTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PayoutPreferencesPage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="ContactDetailsPage.paymentsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
]
|
||||
}
|
||||
<withViewport(LayoutWrapperAccountSettingsSideNavComponent)
|
||||
currentTab="ContactDetailsPage"
|
||||
/>
|
||||
<LayoutWrapperMain
|
||||
className={null}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { isScrollingDisabled } from '../../ducks/UI.duck';
|
|||
import {
|
||||
LayoutSideNavigation,
|
||||
LayoutWrapperMain,
|
||||
LayoutWrapperSideNav,
|
||||
LayoutWrapperAccountSettingsSideNav,
|
||||
LayoutWrapperTopbar,
|
||||
LayoutWrapperFooter,
|
||||
Footer,
|
||||
|
|
@ -33,30 +33,6 @@ export const PasswordChangePageComponent = props => {
|
|||
intl,
|
||||
} = props;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
text: <FormattedMessage id="PasswordChangePage.contactDetailsTabTitle" />,
|
||||
selected: false,
|
||||
linkProps: {
|
||||
name: 'ContactDetailsPage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="PasswordChangePage.passwordTabTitle" />,
|
||||
selected: true,
|
||||
linkProps: {
|
||||
name: 'PasswordChangePage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="PasswordChangePage.paymentsTabTitle" />,
|
||||
selected: false,
|
||||
linkProps: {
|
||||
name: 'PayoutPreferencesPage',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const changePasswordForm =
|
||||
currentUser && currentUser.id ? (
|
||||
<PasswordChangeForm
|
||||
|
|
@ -83,7 +59,7 @@ export const PasswordChangePageComponent = props => {
|
|||
/>
|
||||
<UserNav selectedPageName="PasswordChangePage" />
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperSideNav tabs={tabs} />
|
||||
<LayoutWrapperAccountSettingsSideNav currentTab="PasswordChangePage" />
|
||||
<LayoutWrapperMain>
|
||||
<div className={css.content}>
|
||||
<h1 className={css.title}>
|
||||
|
|
|
|||
|
|
@ -23,43 +23,8 @@ exports[`PasswordChangePage matches snapshot 1`] = `
|
|||
selectedPageName="PasswordChangePage"
|
||||
/>
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperSideNav
|
||||
className={null}
|
||||
rootClassName={null}
|
||||
tabs={
|
||||
Array [
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "ContactDetailsPage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="PasswordChangePage.contactDetailsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PasswordChangePage",
|
||||
},
|
||||
"selected": true,
|
||||
"text": <FormattedMessage
|
||||
id="PasswordChangePage.passwordTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PayoutPreferencesPage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="PasswordChangePage.paymentsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
]
|
||||
}
|
||||
<withViewport(LayoutWrapperAccountSettingsSideNavComponent)
|
||||
currentTab="PasswordChangePage"
|
||||
/>
|
||||
<LayoutWrapperMain
|
||||
className={null}
|
||||
|
|
|
|||
28
src/containers/PaymentMethodsPage/PaymentMethodsPage.css
Normal file
28
src/containers/PaymentMethodsPage/PaymentMethodsPage.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.content {
|
||||
@media (--viewportMedium) {
|
||||
margin: 32px auto 0 auto;
|
||||
width: 100%;
|
||||
max-width: 564px;
|
||||
}
|
||||
|
||||
@media (--viewportLarge) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.desktopTopbar,
|
||||
.mobileTopbar {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
hyphens: auto;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 19px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-bottom: 47px;
|
||||
}
|
||||
}
|
||||
106
src/containers/PaymentMethodsPage/PaymentMethodsPage.duck.js
Normal file
106
src/containers/PaymentMethodsPage/PaymentMethodsPage.duck.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { fetchCurrentUser } from '../../ducks/user.duck';
|
||||
import { setInitialValues as setInitialValuesForPaymentMethods } from '../../ducks/paymentMethods.duck';
|
||||
import { storableError } from '../../util/errors';
|
||||
import * as log from '../../util/log';
|
||||
|
||||
// ================ Action types ================ //
|
||||
|
||||
export const SETUP_INTENT_REQUEST = 'app/PaymentMethodsPage/SETUP_INTENT_REQUEST';
|
||||
export const SETUP_INTENT_SUCCESS = 'app/PaymentMethodsPage/SETUP_INTENT_SUCCESS';
|
||||
export const SETUP_INTENT_ERROR = 'app/PaymentMethodsPage/SETUP_INTENT_ERROR';
|
||||
|
||||
export const STRIPE_CUSTOMER_REQUEST = 'app/PaymentMethodsPage/STRIPE_CUSTOMER_REQUEST';
|
||||
export const STRIPE_CUSTOMER_SUCCESS = 'app/PaymentMethodsPage/STRIPE_CUSTOMER_SUCCESS';
|
||||
export const STRIPE_CUSTOMER_ERROR = 'app/PaymentMethodsPage/STRIPE_CUSTOMER_ERROR';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const initialState = {
|
||||
setupIntentInProgress: false,
|
||||
setupIntentError: null,
|
||||
setupIntent: null,
|
||||
stripeCustomerFetched: false,
|
||||
};
|
||||
|
||||
export default function payoutMethodsPageReducer(state = initialState, action = {}) {
|
||||
const { type, payload } = action;
|
||||
switch (type) {
|
||||
case SETUP_INTENT_REQUEST:
|
||||
return { ...state, setupIntentInProgress: true, setupIntentError: null };
|
||||
case SETUP_INTENT_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
setupIntentInProgress: false,
|
||||
setupIntentError: null,
|
||||
setupIntent: payload,
|
||||
};
|
||||
case SETUP_INTENT_ERROR:
|
||||
console.error(payload); // eslint-disable-line no-console
|
||||
return { ...state, setupIntentInProgress: false, setupIntentError: null };
|
||||
case STRIPE_CUSTOMER_REQUEST:
|
||||
return { ...state, stripeCustomerFetched: false };
|
||||
case STRIPE_CUSTOMER_SUCCESS:
|
||||
return { ...state, stripeCustomerFetched: true };
|
||||
case STRIPE_CUSTOMER_ERROR:
|
||||
console.error(payload); // eslint-disable-line no-console
|
||||
return { ...state, stripeCustomerFetchError: payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ================ Action creators ================ //
|
||||
|
||||
export const setupIntentRequest = () => ({ type: SETUP_INTENT_REQUEST });
|
||||
export const setupIntentSuccess = () => ({ type: SETUP_INTENT_SUCCESS });
|
||||
export const setupIntentError = e => ({
|
||||
type: SETUP_INTENT_ERROR,
|
||||
error: true,
|
||||
payload: e,
|
||||
});
|
||||
|
||||
export const stripeCustomerRequest = () => ({ type: STRIPE_CUSTOMER_REQUEST });
|
||||
export const stripeCustomerSuccess = () => ({ type: STRIPE_CUSTOMER_SUCCESS });
|
||||
export const stripeCustomerError = e => ({
|
||||
type: STRIPE_CUSTOMER_ERROR,
|
||||
error: true,
|
||||
payload: e,
|
||||
});
|
||||
// ================ Thunks ================ //
|
||||
|
||||
export const createStripeSetupIntent = () => (dispatch, getState, sdk) => {
|
||||
dispatch(setupIntentRequest());
|
||||
return sdk.stripeSetupIntents
|
||||
.create()
|
||||
.then(response => {
|
||||
const setupIntent = response.data.data;
|
||||
dispatch(setupIntentSuccess(setupIntent));
|
||||
return setupIntent;
|
||||
})
|
||||
.catch(e => {
|
||||
const error = storableError(e);
|
||||
log.error(error, 'create-setup-intent-failed');
|
||||
dispatch(setupIntentError(error));
|
||||
return { createStripeSetupIntentSuccess: false };
|
||||
});
|
||||
};
|
||||
|
||||
export const stripeCustomer = () => (dispatch, getState, sdk) => {
|
||||
dispatch(stripeCustomerRequest());
|
||||
|
||||
return dispatch(fetchCurrentUser({ include: ['stripeCustomer.defaultPaymentMethod'] }))
|
||||
.then(response => {
|
||||
dispatch(stripeCustomerSuccess());
|
||||
})
|
||||
.catch(e => {
|
||||
const error = storableError(e);
|
||||
log.error(error, 'fetch-stripe-customer-failed');
|
||||
dispatch(stripeCustomerError(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const loadData = () => (dispatch, getState, sdk) => {
|
||||
dispatch(setInitialValuesForPaymentMethods());
|
||||
|
||||
return dispatch(stripeCustomer());
|
||||
};
|
||||
275
src/containers/PaymentMethodsPage/PaymentMethodsPage.js
Normal file
275
src/containers/PaymentMethodsPage/PaymentMethodsPage.js
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import React, { useState } from 'react';
|
||||
import { bool, func, object } from 'prop-types';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
|
||||
import { ensureCurrentUser, ensureStripeCustomer, ensurePaymentMethodCard } from '../../util/data';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { savePaymentMethod, deletePaymentMethod } from '../../ducks/paymentMethods.duck';
|
||||
import { handleCardSetup } from '../../ducks/stripe.duck';
|
||||
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import {
|
||||
SavedCardDetails,
|
||||
LayoutSideNavigation,
|
||||
LayoutWrapperMain,
|
||||
LayoutWrapperAccountSettingsSideNav,
|
||||
LayoutWrapperTopbar,
|
||||
LayoutWrapperFooter,
|
||||
Footer,
|
||||
Page,
|
||||
UserNav,
|
||||
} from '../../components';
|
||||
import { TopbarContainer } from '../../containers';
|
||||
import { PaymentMethodsForm } from '../../forms';
|
||||
import { createStripeSetupIntent, stripeCustomer, loadData } from './PaymentMethodsPage.duck.js';
|
||||
|
||||
import css from './PaymentMethodsPage.css';
|
||||
|
||||
const PaymentMethodsPageComponent = props => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [cardState, setCardState] = useState(null);
|
||||
|
||||
const {
|
||||
currentUser,
|
||||
addPaymentMethodError,
|
||||
deletePaymentMethodError,
|
||||
createStripeCustomerError,
|
||||
handleCardSetupError,
|
||||
deletePaymentMethodInProgress,
|
||||
onCreateSetupIntent,
|
||||
onHandleCardSetup,
|
||||
onSavePaymentMethod,
|
||||
onDeletePaymentMethod,
|
||||
fetchStripeCustomer,
|
||||
scrollingDisabled,
|
||||
onManageDisableScrolling,
|
||||
intl,
|
||||
stripeCustomerFetched,
|
||||
} = props;
|
||||
|
||||
const getClientSecret = setupIntent => {
|
||||
return setupIntent && setupIntent.attributes ? setupIntent.attributes.clientSecret : null;
|
||||
};
|
||||
const getPaymentParams = (currentUser, formValues) => {
|
||||
const { name, addressLine1, addressLine2, postal, state, city, country } = formValues;
|
||||
const addressMaybe =
|
||||
addressLine1 && postal
|
||||
? {
|
||||
address: {
|
||||
city: city,
|
||||
country: country,
|
||||
line1: addressLine1,
|
||||
line2: addressLine2,
|
||||
postal_code: postal,
|
||||
state: state,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const billingDetails = {
|
||||
name,
|
||||
email: ensureCurrentUser(currentUser).attributes.email,
|
||||
...addressMaybe,
|
||||
};
|
||||
|
||||
const paymentParams = {
|
||||
payment_method_data: {
|
||||
billing_details: billingDetails,
|
||||
},
|
||||
};
|
||||
|
||||
return paymentParams;
|
||||
};
|
||||
|
||||
const handleSubmit = params => {
|
||||
setIsSubmitting(true);
|
||||
const ensuredCurrentUser = ensureCurrentUser(currentUser);
|
||||
const stripeCustomer = ensuredCurrentUser.stripeCustomer;
|
||||
const { stripe, card, formValues } = params;
|
||||
|
||||
onCreateSetupIntent()
|
||||
.then(setupIntent => {
|
||||
const stripeParams = {
|
||||
stripe,
|
||||
card,
|
||||
setupIntentClientSecret: getClientSecret(setupIntent),
|
||||
paymentParams: getPaymentParams(currentUser, formValues),
|
||||
};
|
||||
|
||||
return onHandleCardSetup(stripeParams);
|
||||
})
|
||||
.then(result => {
|
||||
const newPaymentMethod = result.setupIntent.payment_method;
|
||||
// Note: stripe.handleCardSetup might return an error inside successful call (200), but those are rejected in thunk functions.
|
||||
|
||||
return onSavePaymentMethod(stripeCustomer, newPaymentMethod);
|
||||
})
|
||||
.then(() => {
|
||||
// Update currentUser entity and its sub entities: stripeCustomer and defaultPaymentMethod
|
||||
fetchStripeCustomer();
|
||||
setIsSubmitting(false);
|
||||
setCardState('default');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemovePaymentMethod = () => {
|
||||
onDeletePaymentMethod().then(() => {
|
||||
fetchStripeCustomer();
|
||||
});
|
||||
};
|
||||
|
||||
const title = intl.formatMessage({ id: 'PaymentMethodsPage.title' });
|
||||
|
||||
const ensuredCurrentUser = ensureCurrentUser(currentUser);
|
||||
const currentUserLoaded = !!ensuredCurrentUser.id;
|
||||
|
||||
const hasDefaultPaymentMethod =
|
||||
currentUser &&
|
||||
ensureStripeCustomer(currentUser.stripeCustomer).attributes.stripeCustomerId &&
|
||||
ensurePaymentMethodCard(currentUser.stripeCustomer.defaultPaymentMethod).id;
|
||||
|
||||
// Get first and last name of the current user and use it in the StripePaymentForm to autofill the name field
|
||||
const userName = currentUserLoaded
|
||||
? `${ensuredCurrentUser.attributes.profile.firstName} ${
|
||||
ensuredCurrentUser.attributes.profile.lastName
|
||||
}`
|
||||
: null;
|
||||
|
||||
const initalValuesForStripePayment = { name: userName };
|
||||
|
||||
const card = hasDefaultPaymentMethod
|
||||
? ensurePaymentMethodCard(currentUser.stripeCustomer.defaultPaymentMethod).attributes.card
|
||||
: null;
|
||||
|
||||
const showForm = cardState === 'replaceCard' || !hasDefaultPaymentMethod;
|
||||
const showCardDetails = !!hasDefaultPaymentMethod;
|
||||
return (
|
||||
<Page title={title} scrollingDisabled={scrollingDisabled}>
|
||||
<LayoutSideNavigation>
|
||||
<LayoutWrapperTopbar>
|
||||
<TopbarContainer
|
||||
currentPage="PaymentMethodsPage"
|
||||
desktopClassName={css.desktopTopbar}
|
||||
mobileClassName={css.mobileTopbar}
|
||||
/>
|
||||
<UserNav selectedPageName="PaymentMethodsPage" />
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperAccountSettingsSideNav currentTab="PaymentMethodsPage" />
|
||||
<LayoutWrapperMain>
|
||||
<div className={css.content}>
|
||||
<h1 className={css.title}>
|
||||
<FormattedMessage id="PaymentMethodsPage.heading" />
|
||||
</h1>
|
||||
{!stripeCustomerFetched ? null : (
|
||||
<>
|
||||
{showCardDetails ? (
|
||||
<SavedCardDetails
|
||||
card={card}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
onChange={setCardState}
|
||||
onDeleteCard={handleRemovePaymentMethod}
|
||||
deletePaymentMethodInProgress={deletePaymentMethodInProgress}
|
||||
/>
|
||||
) : null}
|
||||
{showForm ? (
|
||||
<PaymentMethodsForm
|
||||
className={css.paymentForm}
|
||||
formId="PaymentMethodsForm"
|
||||
initialValues={initalValuesForStripePayment}
|
||||
onSubmit={handleSubmit}
|
||||
handleRemovePaymentMethod={handleRemovePaymentMethod}
|
||||
hasDefaultPaymentMethod={hasDefaultPaymentMethod}
|
||||
addPaymentMethodError={addPaymentMethodError}
|
||||
deletePaymentMethodError={deletePaymentMethodError}
|
||||
createStripeCustomerError={createStripeCustomerError}
|
||||
handleCardSetupError={handleCardSetupError}
|
||||
inProgress={isSubmitting}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</LayoutWrapperMain>
|
||||
<LayoutWrapperFooter>
|
||||
<Footer />
|
||||
</LayoutWrapperFooter>
|
||||
</LayoutSideNavigation>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
PaymentMethodsPageComponent.defaultProps = {
|
||||
currentUser: null,
|
||||
addPaymentMethodError: null,
|
||||
deletePaymentMethodError: null,
|
||||
createStripeCustomerError: null,
|
||||
handleCardSetupError: null,
|
||||
};
|
||||
|
||||
PaymentMethodsPageComponent.propTypes = {
|
||||
currentUser: propTypes.currentUser,
|
||||
scrollingDisabled: bool.isRequired,
|
||||
addPaymentMethodError: object,
|
||||
deletePaymentMethodError: object,
|
||||
createStripeCustomerError: object,
|
||||
handleCardSetupError: object,
|
||||
onCreateSetupIntent: func.isRequired,
|
||||
onHandleCardSetup: func.isRequired,
|
||||
onSavePaymentMethod: func.isRequired,
|
||||
onDeletePaymentMethod: func.isRequired,
|
||||
fetchStripeCustomer: func.isRequired,
|
||||
|
||||
// from injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { currentUser } = state.user;
|
||||
|
||||
const {
|
||||
deletePaymentMethodInProgress,
|
||||
addPaymentMethodError,
|
||||
deletePaymentMethodError,
|
||||
createStripeCustomerError,
|
||||
} = state.paymentMethods;
|
||||
|
||||
const { stripeCustomerFetched } = state.PaymentMethodsPage;
|
||||
|
||||
const { handleCardSetupError } = state.stripe;
|
||||
return {
|
||||
currentUser,
|
||||
scrollingDisabled: isScrollingDisabled(state),
|
||||
deletePaymentMethodInProgress,
|
||||
addPaymentMethodError,
|
||||
deletePaymentMethodError,
|
||||
createStripeCustomerError,
|
||||
handleCardSetupError,
|
||||
stripeCustomerFetched,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onManageDisableScrolling: (componentId, disableScrolling) =>
|
||||
dispatch(manageDisableScrolling(componentId, disableScrolling)),
|
||||
fetchStripeCustomer: () => dispatch(stripeCustomer()),
|
||||
onHandleCardSetup: params => dispatch(handleCardSetup(params)),
|
||||
onCreateSetupIntent: params => dispatch(createStripeSetupIntent(params)),
|
||||
onSavePaymentMethod: (stripeCustomer, newPaymentMethod) =>
|
||||
dispatch(savePaymentMethod(stripeCustomer, newPaymentMethod)),
|
||||
onDeletePaymentMethod: params => dispatch(deletePaymentMethod(params)),
|
||||
});
|
||||
|
||||
const PaymentMethodsPage = compose(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
),
|
||||
injectIntl
|
||||
)(PaymentMethodsPageComponent);
|
||||
|
||||
PaymentMethodsPage.loadData = loadData;
|
||||
|
||||
export default PaymentMethodsPage;
|
||||
|
|
@ -10,7 +10,7 @@ import { stripeAccountClearError } from '../../ducks/stripe.duck';
|
|||
import {
|
||||
LayoutSideNavigation,
|
||||
LayoutWrapperMain,
|
||||
LayoutWrapperSideNav,
|
||||
LayoutWrapperAccountSettingsSideNav,
|
||||
LayoutWrapperTopbar,
|
||||
LayoutWrapperFooter,
|
||||
Footer,
|
||||
|
|
@ -42,30 +42,6 @@ export const PayoutPreferencesPageComponent = props => {
|
|||
!!ensuredCurrentUser.stripeAccount &&
|
||||
!!ensuredCurrentUser.stripeAccount.id;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
text: <FormattedMessage id="PayoutPreferencesPage.contactDetailsTabTitle" />,
|
||||
selected: false,
|
||||
linkProps: {
|
||||
name: 'ContactDetailsPage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="PayoutPreferencesPage.passwordTabTitle" />,
|
||||
selected: false,
|
||||
linkProps: {
|
||||
name: 'PasswordChangePage',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: <FormattedMessage id="PayoutPreferencesPage.paymentsTabTitle" />,
|
||||
selected: true,
|
||||
linkProps: {
|
||||
name: 'PayoutPreferencesPage',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const title = intl.formatMessage({ id: 'PayoutPreferencesPage.title' });
|
||||
const formDisabled = !currentUserLoaded || stripeConnected || payoutDetailsSaved;
|
||||
|
||||
|
|
@ -105,7 +81,7 @@ export const PayoutPreferencesPageComponent = props => {
|
|||
/>
|
||||
<UserNav selectedPageName="PayoutPreferencesPage" />
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperSideNav tabs={tabs} />
|
||||
<LayoutWrapperAccountSettingsSideNav currentTab="PayoutPreferencesPage" />
|
||||
<LayoutWrapperMain>
|
||||
<div className={css.content}>
|
||||
<h1 className={css.title}>
|
||||
|
|
|
|||
|
|
@ -23,43 +23,8 @@ exports[`PayoutPreferencesPage matches snapshot with Stripe connected 1`] = `
|
|||
selectedPageName="PayoutPreferencesPage"
|
||||
/>
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperSideNav
|
||||
className={null}
|
||||
rootClassName={null}
|
||||
tabs={
|
||||
Array [
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "ContactDetailsPage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.contactDetailsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PasswordChangePage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.passwordTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PayoutPreferencesPage",
|
||||
},
|
||||
"selected": true,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.paymentsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
]
|
||||
}
|
||||
<withViewport(LayoutWrapperAccountSettingsSideNavComponent)
|
||||
currentTab="PayoutPreferencesPage"
|
||||
/>
|
||||
<LayoutWrapperMain
|
||||
className={null}
|
||||
|
|
@ -113,43 +78,8 @@ exports[`PayoutPreferencesPage matches snapshot with Stripe not connected 1`] =
|
|||
selectedPageName="PayoutPreferencesPage"
|
||||
/>
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperSideNav
|
||||
className={null}
|
||||
rootClassName={null}
|
||||
tabs={
|
||||
Array [
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "ContactDetailsPage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.contactDetailsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PasswordChangePage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.passwordTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PayoutPreferencesPage",
|
||||
},
|
||||
"selected": true,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.paymentsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
]
|
||||
}
|
||||
<withViewport(LayoutWrapperAccountSettingsSideNavComponent)
|
||||
currentTab="PayoutPreferencesPage"
|
||||
/>
|
||||
<LayoutWrapperMain
|
||||
className={null}
|
||||
|
|
@ -218,43 +148,8 @@ exports[`PayoutPreferencesPage matches snapshot with details submitted 1`] = `
|
|||
selectedPageName="PayoutPreferencesPage"
|
||||
/>
|
||||
</LayoutWrapperTopbar>
|
||||
<LayoutWrapperSideNav
|
||||
className={null}
|
||||
rootClassName={null}
|
||||
tabs={
|
||||
Array [
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "ContactDetailsPage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.contactDetailsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PasswordChangePage",
|
||||
},
|
||||
"selected": false,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.passwordTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"linkProps": Object {
|
||||
"name": "PayoutPreferencesPage",
|
||||
},
|
||||
"selected": true,
|
||||
"text": <FormattedMessage
|
||||
id="PayoutPreferencesPage.paymentsTabTitle"
|
||||
values={Object {}}
|
||||
/>,
|
||||
},
|
||||
]
|
||||
}
|
||||
<withViewport(LayoutWrapperAccountSettingsSideNavComponent)
|
||||
currentTab="PayoutPreferencesPage"
|
||||
/>
|
||||
<LayoutWrapperMain
|
||||
className={null}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ const initialState = {
|
|||
oldestMessagePageFetched: 0,
|
||||
messages: [],
|
||||
initialMessageFailedToTransaction: null,
|
||||
savePaymentMethodFailed: false,
|
||||
sendMessageInProgress: false,
|
||||
sendMessageError: null,
|
||||
sendReviewInProgress: false,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export const TransactionPageComponent = props => {
|
|||
const {
|
||||
currentUser,
|
||||
initialMessageFailedToTransaction,
|
||||
savePaymentMethodFailed,
|
||||
fetchMessagesError,
|
||||
fetchMessagesInProgress,
|
||||
totalMessagePages,
|
||||
|
|
@ -105,7 +106,11 @@ export const TransactionPageComponent = props => {
|
|||
};
|
||||
|
||||
// If payment is pending, redirect to CheckoutPage
|
||||
if (txIsPaymentPending(currentTransaction) && isCustomerRole) {
|
||||
if (
|
||||
txIsPaymentPending(currentTransaction) &&
|
||||
isCustomerRole &&
|
||||
currentTransaction.attributes.lineItems
|
||||
) {
|
||||
const currentBooking = ensureListing(currentTransaction.booking);
|
||||
|
||||
const initialValues = {
|
||||
|
|
@ -217,6 +222,7 @@ export const TransactionPageComponent = props => {
|
|||
oldestMessagePageFetched={oldestMessagePageFetched}
|
||||
messages={messages}
|
||||
initialMessageFailed={initialMessageFailed}
|
||||
savePaymentMethodFailed={savePaymentMethodFailed}
|
||||
fetchMessagesError={fetchMessagesError}
|
||||
sendMessageInProgress={sendMessageInProgress}
|
||||
sendMessageError={sendMessageError}
|
||||
|
|
@ -270,6 +276,7 @@ TransactionPageComponent.defaultProps = {
|
|||
transaction: null,
|
||||
fetchMessagesError: null,
|
||||
initialMessageFailedToTransaction: null,
|
||||
savePaymentMethodFailed: false,
|
||||
sendMessageError: null,
|
||||
timeSlots: null,
|
||||
fetchTimeSlotsError: null,
|
||||
|
|
@ -295,6 +302,7 @@ TransactionPageComponent.propTypes = {
|
|||
oldestMessagePageFetched: number.isRequired,
|
||||
messages: arrayOf(propTypes.message).isRequired,
|
||||
initialMessageFailedToTransaction: propTypes.uuid,
|
||||
savePaymentMethodFailed: bool,
|
||||
sendMessageInProgress: bool.isRequired,
|
||||
sendMessageError: propTypes.error,
|
||||
onShowMoreMessages: func.isRequired,
|
||||
|
|
@ -330,6 +338,7 @@ const mapStateToProps = state => {
|
|||
oldestMessagePageFetched,
|
||||
messages,
|
||||
initialMessageFailedToTransaction,
|
||||
savePaymentMethodFailed,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
|
|
@ -358,6 +367,7 @@ const mapStateToProps = state => {
|
|||
oldestMessagePageFetched,
|
||||
messages,
|
||||
initialMessageFailedToTransaction,
|
||||
savePaymentMethodFailed,
|
||||
sendMessageInProgress,
|
||||
sendMessageError,
|
||||
sendReviewInProgress,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ exports[`TransactionPage - Order matches snapshot 1`] = `
|
|||
onSendMessage={[Function]}
|
||||
onShowMoreMessages={[Function]}
|
||||
onSubmitBookingRequest={[Function]}
|
||||
savePaymentMethodFailed={false}
|
||||
sendMessageError={null}
|
||||
sendMessageInProgress={false}
|
||||
timeSlots={null}
|
||||
|
|
@ -280,6 +281,7 @@ exports[`TransactionPage - Sale matches snapshot 1`] = `
|
|||
onSendMessage={[Function]}
|
||||
onShowMoreMessages={[Function]}
|
||||
onSubmitBookingRequest={[Function]}
|
||||
savePaymentMethodFailed={false}
|
||||
sendMessageError={null}
|
||||
sendMessageInProgress={false}
|
||||
timeSlots={null}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export { default as NotFoundPage } from './NotFoundPage/NotFoundPage';
|
|||
export { default as PasswordChangePage } from './PasswordChangePage/PasswordChangePage';
|
||||
export { default as PasswordRecoveryPage } from './PasswordRecoveryPage/PasswordRecoveryPage';
|
||||
export { default as PasswordResetPage } from './PasswordResetPage/PasswordResetPage';
|
||||
export { default as PaymentMethodsPage } from './PaymentMethodsPage/PaymentMethodsPage';
|
||||
export { default as PayoutPreferencesPage } from './PayoutPreferencesPage/PayoutPreferencesPage';
|
||||
export { default as PrivacyPolicyPage } from './PrivacyPolicyPage/PrivacyPolicyPage';
|
||||
export { default as ProfilePage } from './ProfilePage/ProfilePage';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import PasswordChangePage from './PasswordChangePage/PasswordChangePage.duck';
|
|||
import PasswordRecoveryPage from './PasswordRecoveryPage/PasswordRecoveryPage.duck';
|
||||
import PasswordResetPage from './PasswordResetPage/PasswordResetPage.duck';
|
||||
import PayoutPreferencesPage from './PayoutPreferencesPage/PayoutPreferencesPage.duck';
|
||||
import PaymentMethodsPage from './PaymentMethodsPage/PaymentMethodsPage.duck';
|
||||
import ProfilePage from './ProfilePage/ProfilePage.duck';
|
||||
import ProfileSettingsPage from './ProfileSettingsPage/ProfileSettingsPage.duck';
|
||||
import SearchPage from './SearchPage/SearchPage.duck';
|
||||
|
|
@ -29,6 +30,7 @@ export {
|
|||
PasswordRecoveryPage,
|
||||
PasswordResetPage,
|
||||
PayoutPreferencesPage,
|
||||
PaymentMethodsPage,
|
||||
ProfilePage,
|
||||
ProfileSettingsPage,
|
||||
SearchPage,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import LocationFilter from './LocationFilter.duck';
|
|||
import Routing from './Routing.duck';
|
||||
import UI from './UI.duck';
|
||||
import marketplaceData from './marketplaceData.duck';
|
||||
import paymentMethods from './paymentMethods.duck';
|
||||
import stripe from './stripe.duck';
|
||||
import user from './user.duck';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ export {
|
|||
Routing,
|
||||
UI,
|
||||
marketplaceData,
|
||||
paymentMethods,
|
||||
stripe,
|
||||
user,
|
||||
};
|
||||
|
|
|
|||
226
src/ducks/paymentMethods.duck.js
Normal file
226
src/ducks/paymentMethods.duck.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import pick from 'lodash/pick';
|
||||
import { storableError } from '../util/errors';
|
||||
import * as log from '../util/log';
|
||||
|
||||
// ================ Action types ================ //
|
||||
|
||||
export const SET_INITIAL_VALUES = 'app/paymentMethods/SET_INITIAL_VALUES';
|
||||
|
||||
export const CREATE_STRIPE_CUSTOMER_REQUEST = 'app/paymentMethods/CREATE_STRIPE_CUSTOMER_REQUEST';
|
||||
export const CREATE_STRIPE_CUSTOMER_SUCCESS = 'app/paymentMethods/CREATE_STRIPE_CUSTOMER_SUCCESS';
|
||||
export const CREATE_STRIPE_CUSTOMER_ERROR = 'app/paymentMethods/CREATE_STRIPE_CUSTOMER_ERROR';
|
||||
|
||||
export const ADD_PAYMENT_METHOD_REQUEST = 'app/paymentMethods/ADD_PAYMENT_METHOD_REQUEST';
|
||||
export const ADD_PAYMENT_METHOD_SUCCESS = 'app/paymentMethods/ADD_PAYMENT_METHOD_SUCCESS';
|
||||
export const ADD_PAYMENT_METHOD_ERROR = 'app/paymentMethods/ADD_PAYMENT_METHOD_ERROR';
|
||||
|
||||
export const DELETE_PAYMENT_METHOD_REQUEST = 'app/paymentMethods/DELETE_PAYMENT_METHOD_REQUEST';
|
||||
export const DELETE_PAYMENT_METHOD_SUCCESS = 'app/paymentMethods/DELETE_PAYMENT_METHOD_SUCCESS';
|
||||
export const DELETE_PAYMENT_METHOD_ERROR = 'app/paymentMethods/DELETE_PAYMENT_METHOD_ERROR';
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const initialState = {
|
||||
addPaymentMethodInProgress: null,
|
||||
addPaymentMethodError: null,
|
||||
deletePaymentMethodInProgress: null,
|
||||
deletePaymentMethodError: null,
|
||||
createStripeCustomerInProgress: null,
|
||||
createStripeCustomerError: null,
|
||||
stripeCustomer: null,
|
||||
};
|
||||
|
||||
export default function payoutMethodsPageReducer(state = initialState, action = {}) {
|
||||
const { type, payload } = action;
|
||||
switch (type) {
|
||||
case SET_INITIAL_VALUES:
|
||||
return { ...initialState, ...payload };
|
||||
case CREATE_STRIPE_CUSTOMER_REQUEST:
|
||||
return { ...state, createStripeCustomerError: null, createStripeCustomerInProgress: true };
|
||||
case CREATE_STRIPE_CUSTOMER_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
createStripeCustomerInProgress: false,
|
||||
stripeCustomer: payload,
|
||||
};
|
||||
case CREATE_STRIPE_CUSTOMER_ERROR:
|
||||
console.error(payload);
|
||||
return {
|
||||
...state,
|
||||
createStripeCustomerError: payload,
|
||||
createStripeCustomerInProgress: false,
|
||||
};
|
||||
case ADD_PAYMENT_METHOD_REQUEST:
|
||||
return { ...state, addPaymentMethodError: null, addPaymentMethodInProgress: true };
|
||||
case ADD_PAYMENT_METHOD_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
addPaymentMethodInProgress: false,
|
||||
stripeCustomer: payload,
|
||||
};
|
||||
case ADD_PAYMENT_METHOD_ERROR:
|
||||
console.error(payload);
|
||||
return {
|
||||
...state,
|
||||
addPaymentMethodError: payload,
|
||||
addPaymentMethodInProgress: false,
|
||||
};
|
||||
|
||||
case DELETE_PAYMENT_METHOD_REQUEST:
|
||||
return { ...state, deletePaymentMethodError: null, deletePaymentMethodInProgress: true };
|
||||
case DELETE_PAYMENT_METHOD_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
deletePaymentMethodInProgress: false,
|
||||
};
|
||||
case DELETE_PAYMENT_METHOD_ERROR:
|
||||
console.error(payload);
|
||||
return {
|
||||
...state,
|
||||
deletePaymentMethodError: payload,
|
||||
deletePaymentMethodInProgress: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ================ Action creators ================ //
|
||||
|
||||
export const setInitialValues = initialValues => ({
|
||||
type: SET_INITIAL_VALUES,
|
||||
payload: pick(initialValues, Object.keys(initialState)),
|
||||
});
|
||||
|
||||
export const stripeCustomerCreateRequest = () => ({ type: CREATE_STRIPE_CUSTOMER_REQUEST });
|
||||
|
||||
export const stripeCustomerCreateSuccess = stripeCustomer => ({
|
||||
type: CREATE_STRIPE_CUSTOMER_SUCCESS,
|
||||
payload: stripeCustomer,
|
||||
});
|
||||
|
||||
export const stripeCustomerCreateError = e => ({
|
||||
type: CREATE_STRIPE_CUSTOMER_ERROR,
|
||||
payload: e,
|
||||
error: true,
|
||||
});
|
||||
|
||||
export const addPaymentMethodRequest = () => ({ type: ADD_PAYMENT_METHOD_REQUEST });
|
||||
|
||||
export const addPaymentMethodSuccess = stripeCustomer => ({
|
||||
type: ADD_PAYMENT_METHOD_SUCCESS,
|
||||
payload: stripeCustomer,
|
||||
});
|
||||
|
||||
export const addPaymentMethodError = e => ({
|
||||
type: ADD_PAYMENT_METHOD_ERROR,
|
||||
payload: e,
|
||||
error: true,
|
||||
});
|
||||
|
||||
export const deletePaymentMethodRequest = () => ({ type: DELETE_PAYMENT_METHOD_REQUEST });
|
||||
|
||||
export const deletePaymentMethodSuccess = stripeCustomer => ({
|
||||
type: DELETE_PAYMENT_METHOD_SUCCESS,
|
||||
payload: stripeCustomer,
|
||||
});
|
||||
|
||||
export const deletePaymentMethodError = e => ({
|
||||
type: DELETE_PAYMENT_METHOD_ERROR,
|
||||
payload: e,
|
||||
error: true,
|
||||
});
|
||||
|
||||
// ================ Thunks ================ //
|
||||
|
||||
export const createStripeCustomer = stripePaymentMethodId => (dispatch, getState, sdk) => {
|
||||
dispatch(stripeCustomerCreateRequest());
|
||||
return sdk.stripeCustomer
|
||||
.create({ stripePaymentMethodId }, { expand: true, include: ['defaultPaymentMethod'] })
|
||||
.then(response => {
|
||||
const stripeCustomer = response.data.data;
|
||||
dispatch(stripeCustomerCreateSuccess(stripeCustomer));
|
||||
return stripeCustomer;
|
||||
})
|
||||
.catch(e => {
|
||||
log.error(storableError(e), 'create-stripe-user-failed');
|
||||
dispatch(stripeCustomerCreateError(storableError(e)));
|
||||
});
|
||||
};
|
||||
|
||||
export const addPaymentMethod = stripePaymentMethodId => (dispatch, getState, sdk) => {
|
||||
dispatch(addPaymentMethodRequest());
|
||||
return sdk.stripeCustomer
|
||||
.addPaymentMethod({ stripePaymentMethodId }, { expand: true })
|
||||
.then(response => {
|
||||
const stripeCustomer = response.data.data;
|
||||
dispatch(addPaymentMethodSuccess(stripeCustomer));
|
||||
return stripeCustomer;
|
||||
})
|
||||
.catch(e => {
|
||||
log.error(storableError(e), 'add-payment-method-failed');
|
||||
dispatch(addPaymentMethodError(storableError(e)));
|
||||
});
|
||||
};
|
||||
|
||||
export const deletePaymentMethod = () => (dispatch, getState, sdk) => {
|
||||
dispatch(deletePaymentMethodRequest());
|
||||
return sdk.stripeCustomer
|
||||
.deletePaymentMethod({}, { expand: true })
|
||||
.then(response => {
|
||||
const stripeCustomer = response.data.data;
|
||||
dispatch(deletePaymentMethodSuccess(stripeCustomer));
|
||||
return stripeCustomer;
|
||||
})
|
||||
.catch(e => {
|
||||
log.error(storableError(e), 'add-payment-method-failed');
|
||||
dispatch(deletePaymentMethodError(storableError(e)));
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePaymentMethod = stripePaymentMethodId => (dispatch, getState, sdk) => {
|
||||
return dispatch(deletePaymentMethod())
|
||||
.then(() => {
|
||||
return dispatch(addPaymentMethod(stripePaymentMethodId));
|
||||
})
|
||||
.catch(e => {
|
||||
log.error(storableError(e), 'updating-payment-method-failed');
|
||||
});
|
||||
};
|
||||
|
||||
// This function helps to choose correct thunk function
|
||||
export const savePaymentMethod = (stripeCustomer, stripePaymentMethodId) => (
|
||||
dispatch,
|
||||
getState,
|
||||
sdk
|
||||
) => {
|
||||
const hasAlreadyDefaultPaymentMethod =
|
||||
stripeCustomer && stripeCustomer.defaultPaymentMethod && stripeCustomer.defaultPaymentMethod.id;
|
||||
|
||||
const savePromise =
|
||||
!stripeCustomer || !stripeCustomer.id
|
||||
? dispatch(createStripeCustomer(stripePaymentMethodId))
|
||||
: hasAlreadyDefaultPaymentMethod
|
||||
? dispatch(updatePaymentMethod(stripePaymentMethodId))
|
||||
: dispatch(addPaymentMethod(stripePaymentMethodId));
|
||||
|
||||
return savePromise
|
||||
.then(response => {
|
||||
const {
|
||||
createStripeCustomerError,
|
||||
addPaymentMethodError,
|
||||
deletePaymentMethodError,
|
||||
} = getState().paymentMethods;
|
||||
|
||||
// If there are any errors, return those errors
|
||||
if (createStripeCustomerError || addPaymentMethodError || deletePaymentMethodError) {
|
||||
return {
|
||||
errors: { createStripeCustomerError, addPaymentMethodError, deletePaymentMethodError },
|
||||
};
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(e => {
|
||||
// errors are already catched in other thunk functions.
|
||||
});
|
||||
};
|
||||
|
|
@ -24,6 +24,10 @@ export const HANDLE_CARD_PAYMENT_REQUEST = 'app/stripe/HANDLE_CARD_PAYMENT_REQUE
|
|||
export const HANDLE_CARD_PAYMENT_SUCCESS = 'app/stripe/HANDLE_CARD_PAYMENT_SUCCESS';
|
||||
export const HANDLE_CARD_PAYMENT_ERROR = 'app/stripe/HANDLE_CARD_PAYMENT_ERROR';
|
||||
|
||||
export const HANDLE_CARD_SETUP_REQUEST = 'app/stripe/HANDLE_CARD_SETUP_REQUEST';
|
||||
export const HANDLE_CARD_SETUP_SUCCESS = 'app/stripe/HANDLE_CARD_SETUP_SUCCESS';
|
||||
export const HANDLE_CARD_SETUP_ERROR = 'app/stripe/HANDLE_CARD_SETUP_ERROR';
|
||||
|
||||
export const CLEAR_HANDLE_CARD_PAYMENT = 'app/stripe/CLEAR_HANDLE_CARD_PAYMENT';
|
||||
|
||||
export const RETRIEVE_PAYMENT_INTENT_REQUEST = 'app/stripe/RETRIEVE_PAYMENT_INTENT_REQUEST';
|
||||
|
|
@ -43,7 +47,10 @@ const initialState = {
|
|||
stripeAccountFetched: false,
|
||||
handleCardPaymentInProgress: false,
|
||||
handleCardPaymentError: null,
|
||||
handleCardSetupInProgress: false,
|
||||
handleCardSetupError: null,
|
||||
paymentIntent: null,
|
||||
setupIntent: null,
|
||||
retrievePaymentIntentInProgress: false,
|
||||
retrievePaymentIntentError: null,
|
||||
};
|
||||
|
|
@ -123,6 +130,18 @@ export default function reducer(state = initialState, action = {}) {
|
|||
console.error(payload);
|
||||
return { ...state, handleCardPaymentError: payload, handleCardPaymentInProgress: false };
|
||||
|
||||
case HANDLE_CARD_SETUP_REQUEST:
|
||||
return {
|
||||
...state,
|
||||
handleCardSetupError: null,
|
||||
handleCardSetupInProgress: true,
|
||||
};
|
||||
case HANDLE_CARD_SETUP_SUCCESS:
|
||||
return { ...state, setupIntent: payload, handleCardSetupInProgress: false };
|
||||
case HANDLE_CARD_SETUP_ERROR:
|
||||
console.error(payload);
|
||||
return { ...state, handleCardSetupError: payload, handleCardSetupInProgress: false };
|
||||
|
||||
case CLEAR_HANDLE_CARD_PAYMENT:
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -218,6 +237,21 @@ export const handleCardPaymentError = payload => ({
|
|||
error: true,
|
||||
});
|
||||
|
||||
export const handleCardSetupRequest = () => ({
|
||||
type: HANDLE_CARD_SETUP_REQUEST,
|
||||
});
|
||||
|
||||
export const handleCardSetupSuccess = payload => ({
|
||||
type: HANDLE_CARD_SETUP_SUCCESS,
|
||||
payload,
|
||||
});
|
||||
|
||||
export const handleCardSetupError = payload => ({
|
||||
type: HANDLE_CARD_SETUP_ERROR,
|
||||
payload,
|
||||
error: true,
|
||||
});
|
||||
|
||||
export const initializeCardPaymentData = () => ({
|
||||
type: CLEAR_HANDLE_CARD_PAYMENT,
|
||||
});
|
||||
|
|
@ -617,8 +651,15 @@ export const handleCardPayment = params => dispatch => {
|
|||
|
||||
dispatch(handleCardPaymentRequest());
|
||||
|
||||
// When using default payment method, card (aka Stripe Element) is not needed.
|
||||
// We also set paymentParams.payment_method already in Flex API side,
|
||||
// when request-payment transition is made - so there's no need for paymentParams
|
||||
const args = card
|
||||
? [stripePaymentIntentClientSecret, card, paymentParams]
|
||||
: [stripePaymentIntentClientSecret];
|
||||
|
||||
return stripe
|
||||
.handleCardPayment(stripePaymentIntentClientSecret, card, paymentParams)
|
||||
.handleCardPayment(...args)
|
||||
.then(response => {
|
||||
if (response.error) {
|
||||
return Promise.reject(response);
|
||||
|
|
@ -649,3 +690,43 @@ export const handleCardPayment = params => dispatch => {
|
|||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
export const handleCardSetup = params => dispatch => {
|
||||
// It's required to use the same instance of Stripe as where the card has been created
|
||||
// so that's why Stripe needs to be passed here and we can't create a new instance.
|
||||
const { stripe, card, setupIntentClientSecret, paymentParams } = params;
|
||||
|
||||
dispatch(handleCardSetupRequest());
|
||||
|
||||
return stripe
|
||||
.handleCardSetup(setupIntentClientSecret, card, paymentParams)
|
||||
.then(response => {
|
||||
if (response.error) {
|
||||
return Promise.reject(response);
|
||||
} else {
|
||||
dispatch(handleCardSetupSuccess(response));
|
||||
return response;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
// Unwrap Stripe error.
|
||||
const e = err.error || storableError(err);
|
||||
dispatch(handleCardSetupError(e));
|
||||
|
||||
// Log error
|
||||
const containsSetupIntent = err.error && err.error.setup_intent;
|
||||
const { code, doc_url, message, setup_intent } = containsSetupIntent ? err.error : {};
|
||||
const loggableError = containsSetupIntent
|
||||
? {
|
||||
code,
|
||||
message,
|
||||
doc_url,
|
||||
paymentIntentStatus: setup_intent.status,
|
||||
}
|
||||
: e;
|
||||
log.error(loggableError, 'stripe-handle-card-setup-failed', {
|
||||
stripeMessage: loggableError.message,
|
||||
});
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,6 +40,20 @@ export const SEND_VERIFICATION_EMAIL_ERROR = 'app/user/SEND_VERIFICATION_EMAIL_E
|
|||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
const mergeCurrentUser = (oldCurrentUser, newCurrentUser) => {
|
||||
const { id: oId, type: oType, attributes: oAttr, ...oldRelationships } = oldCurrentUser || {};
|
||||
const { id, type, attributes, ...relationships } = newCurrentUser || {};
|
||||
|
||||
// Passing null will remove currentUser entity.
|
||||
// Only relationships are merged.
|
||||
// TODO figure out if sparse fields handling needs a better handling.
|
||||
return newCurrentUser === null
|
||||
? null
|
||||
: oldCurrentUser === null
|
||||
? newCurrentUser
|
||||
: { id, type, attributes, ...oldRelationships, ...relationships };
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
currentUser: null,
|
||||
currentUserShowError: null,
|
||||
|
|
@ -59,7 +73,7 @@ export default function reducer(state = initialState, action = {}) {
|
|||
case CURRENT_USER_SHOW_REQUEST:
|
||||
return { ...state, currentUserShowError: null };
|
||||
case CURRENT_USER_SHOW_SUCCESS:
|
||||
return { ...state, currentUser: payload };
|
||||
return { ...state, currentUser: mergeCurrentUser(state.currentUser, payload) };
|
||||
case CURRENT_USER_SHOW_ERROR:
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(payload);
|
||||
|
|
@ -291,7 +305,7 @@ export const fetchCurrentUserNotifications = () => (dispatch, getState, sdk) =>
|
|||
.catch(e => dispatch(fetchCurrentUserNotificationsError(storableError(e))));
|
||||
};
|
||||
|
||||
export const fetchCurrentUser = () => (dispatch, getState, sdk) => {
|
||||
export const fetchCurrentUser = (params = null) => (dispatch, getState, sdk) => {
|
||||
dispatch(currentUserShowRequest());
|
||||
const { isAuthenticated } = getState().Auth;
|
||||
|
||||
|
|
@ -301,13 +315,13 @@ export const fetchCurrentUser = () => (dispatch, getState, sdk) => {
|
|||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
const params = {
|
||||
const parameters = params || {
|
||||
include: ['profileImage', 'stripeAccount'],
|
||||
'fields.image': ['variants.square-small', 'variants.square-small2x'],
|
||||
};
|
||||
|
||||
return sdk.currentUser
|
||||
.show(params)
|
||||
.show(parameters)
|
||||
.then(response => {
|
||||
const entities = denormalisedResponseEntities(response);
|
||||
if (entities.length !== 1) {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import * as RangeSlider from './components/RangeSlider/RangeSlider.example';
|
|||
import * as ResponsiveImage from './components/ResponsiveImage/ResponsiveImage.example';
|
||||
import * as ReviewRating from './components/ReviewRating/ReviewRating.example';
|
||||
import * as Reviews from './components/Reviews/Reviews.example';
|
||||
import * as SavedCardDetails from './components/SavedCardDetails/SavedCardDetails.example';
|
||||
import * as SectionThumbnailLinks from './components/SectionThumbnailLinks/SectionThumbnailLinks.example';
|
||||
import * as SelectMultipleFilter from './components/SelectMultipleFilter/SelectMultipleFilter.example';
|
||||
import * as StripeBankAccountTokenInputField from './components/StripeBankAccountTokenInputField/StripeBankAccountTokenInputField.example';
|
||||
|
|
@ -171,6 +172,7 @@ export {
|
|||
ReviewForm,
|
||||
ReviewRating,
|
||||
Reviews,
|
||||
SavedCardDetails,
|
||||
SectionThumbnailLinks,
|
||||
SelectMultipleFilter,
|
||||
SendMessageForm,
|
||||
|
|
|
|||
165
src/forms/PaymentMethodsForm/PaymentMethodsForm.css
Normal file
165
src/forms/PaymentMethodsForm/PaymentMethodsForm.css
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply --marketplaceInputStyles;
|
||||
|
||||
/* Layout */
|
||||
padding: 8px 0 6px 0;
|
||||
height: 36px;
|
||||
|
||||
/* Border */
|
||||
border-bottom-color: var(--attentionColor);
|
||||
|
||||
@media (--viewportMedium) {
|
||||
height: 35px;
|
||||
}
|
||||
@media (--viewportLarge) {
|
||||
height: 38px;
|
||||
padding: 6px 0 14px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardSuccess {
|
||||
border-bottom-color: var(--successColor);
|
||||
}
|
||||
|
||||
.cardError {
|
||||
border-bottom-color: var(--failColor);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--failColor);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin-top: 24px;
|
||||
color: var(--failColor);
|
||||
}
|
||||
|
||||
.paymentHeading {
|
||||
margin: 0 0 14px 0;
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
padding-top: 8px;
|
||||
padding-bottom: 0px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 0 0 26px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.billingHeading {
|
||||
margin: 0 0 14px 0;
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
padding-top: 3px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 0 0 26px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.paymentLabel {
|
||||
margin: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@media (--viewportLarge) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.messageHeading {
|
||||
color: var(--matterColorAnti);
|
||||
margin: 40px 0 14px 0;
|
||||
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 41px 0 26px 0;
|
||||
}
|
||||
@media (--viewportLarge) {
|
||||
margin: 40px 0 26px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.messageLabel {
|
||||
margin: 0 0 5px 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.messageOptional {
|
||||
color: var(--matterColorAnti);
|
||||
}
|
||||
|
||||
.message {
|
||||
border-bottom-color: var(--matterColorAnti);
|
||||
}
|
||||
|
||||
.submitContainer {
|
||||
margin-top: auto;
|
||||
margin-top: 48px;
|
||||
@media (--viewportLarge) {
|
||||
margin-top: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
.infoText {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
color: var(--matterColorAnti);
|
||||
padding: 0;
|
||||
margin: 14px 0 0 0;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
margin-top: 22px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 26px;
|
||||
}
|
||||
@media (--viewportLarge) {
|
||||
margin-top: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.missingStripeKey {
|
||||
color: var(--failColor);
|
||||
}
|
||||
|
||||
.paymentAddressField {
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.postalCode {
|
||||
margin-top: 24px;
|
||||
width: calc(40% - 9px);
|
||||
}
|
||||
|
||||
.city {
|
||||
margin-top: 24px;
|
||||
width: calc(60% - 9px);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: 24px;
|
||||
}
|
||||
307
src/forms/PaymentMethodsForm/PaymentMethodsForm.js
Normal file
307
src/forms/PaymentMethodsForm/PaymentMethodsForm.js
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
/**
|
||||
* Note: This form is using card from Stripe Elements https://stripe.com/docs/stripe-js#elements
|
||||
* Card is not a Final Form field so it's not available trough Final Form.
|
||||
* It's also handled separately in handleSubmit function.
|
||||
*/
|
||||
import React, { Component } from 'react';
|
||||
import { bool, func, object, string } from 'prop-types';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
import { Form as FinalForm } from 'react-final-form';
|
||||
import classNames from 'classnames';
|
||||
import config from '../../config';
|
||||
import { Form, PrimaryButton, FieldTextInput, StripePaymentAddress } from '../../components';
|
||||
import css from './PaymentMethodsForm.css';
|
||||
|
||||
/**
|
||||
* Translate a Stripe API error object.
|
||||
*
|
||||
* To keep up with possible keys from the Stripe API, see:
|
||||
*
|
||||
* https://stripe.com/docs/api#errors
|
||||
*
|
||||
* Note that at least at moment, the above link doesn't list all the
|
||||
* error codes that the API returns.
|
||||
*
|
||||
* @param {Object} intl - react-intl object from injectIntl
|
||||
* @param {Object} stripeError - error object from Stripe API
|
||||
*
|
||||
* @return {String} translation message for the specific Stripe error,
|
||||
* or the given error message (not translated) if the specific error
|
||||
* type/code is not defined in the translations
|
||||
*
|
||||
*/
|
||||
const stripeErrorTranslation = (intl, stripeError) => {
|
||||
const { message, code, type } = stripeError;
|
||||
|
||||
if (!code || !type) {
|
||||
// Not a proper Stripe error object
|
||||
return intl.formatMessage({ id: 'PaymentMethodsForm.genericError' });
|
||||
}
|
||||
|
||||
const translationId =
|
||||
type === 'validation_error'
|
||||
? `PaymentMethodsForm.stripe.validation_error.${code}`
|
||||
: `PaymentMethodsForm.stripe.${type}`;
|
||||
|
||||
return intl.formatMessage({
|
||||
id: translationId,
|
||||
defaultMessage: message,
|
||||
});
|
||||
};
|
||||
|
||||
const stripeElementsOptions = {
|
||||
fonts: [
|
||||
{
|
||||
family: 'sofiapro',
|
||||
fontSmoothing: 'antialiased',
|
||||
src:
|
||||
'local("sofiapro"), local("SofiaPro"), local("Sofia Pro"), url("https://assets-sharetribecom.sharetribe.com/webfonts/sofiapro/sofiapro-medium-webfont.woff2") format("woff2")',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const cardStyles = {
|
||||
base: {
|
||||
fontFamily: '"sofiapro", Helvetica, Arial, sans-serif',
|
||||
fontSize: '18px',
|
||||
fontSmoothing: 'antialiased',
|
||||
lineHeight: '24px',
|
||||
letterSpacing: '-0.1px',
|
||||
color: '#4A4A4A',
|
||||
'::placeholder': {
|
||||
color: '#B2B2B2',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
error: null,
|
||||
cardValueValid: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Payment methods form that asks for credit card info using Stripe Elements.
|
||||
*
|
||||
* When the card is valid and the user submits the form, a request is
|
||||
* sent to the Stripe API to handle card setup. `stripe.handleCardSetup`
|
||||
* may ask more details from cardholder if 3D security steps are needed.
|
||||
*
|
||||
* See: https://stripe.com/docs/payments/payment-intents
|
||||
* https://stripe.com/docs/elements
|
||||
*/
|
||||
class PaymentMethodsForm extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = initialState;
|
||||
this.handleCardValueChange = this.handleCardValueChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.paymentForm = this.paymentForm.bind(this);
|
||||
this.finalFormAPI = null;
|
||||
this.stripe = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!window.Stripe) {
|
||||
throw new Error('Stripe must be loaded for PaymentMethodsForm');
|
||||
}
|
||||
|
||||
if (config.stripe.publishableKey) {
|
||||
this.stripe = window.Stripe(config.stripe.publishableKey);
|
||||
|
||||
const elements = this.stripe.elements(stripeElementsOptions);
|
||||
this.card = elements.create('card', { style: cardStyles });
|
||||
this.card.mount(this.cardContainer);
|
||||
this.card.addEventListener('change', this.handleCardValueChange);
|
||||
// EventListener is the only way to simulate breakpoints with Stripe.
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
this.card.update({ style: { base: { fontSize: '18px', lineHeight: '24px' } } });
|
||||
} else {
|
||||
this.card.update({ style: { base: { fontSize: '20px', lineHeight: '32px' } } });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.card) {
|
||||
this.card.removeEventListener('change', this.handleCardValueChange);
|
||||
this.card.unmount();
|
||||
}
|
||||
}
|
||||
handleCardValueChange(event) {
|
||||
const { intl } = this.props;
|
||||
const { error, complete } = event;
|
||||
|
||||
const postalCode = event.value.postalCode;
|
||||
if (this.finalFormAPI) {
|
||||
this.finalFormAPI.change('postal', postalCode);
|
||||
}
|
||||
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
error: error ? stripeErrorTranslation(intl, error) : null,
|
||||
cardValueValid: complete,
|
||||
};
|
||||
});
|
||||
}
|
||||
handleSubmit(values) {
|
||||
const { onSubmit, inProgress, formId } = this.props;
|
||||
const cardInputNeedsAttention = !this.state.cardValueValid;
|
||||
|
||||
if (inProgress || cardInputNeedsAttention) {
|
||||
// Already submitting or card value incomplete/invalid
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {
|
||||
stripe: this.stripe,
|
||||
card: this.card,
|
||||
formId,
|
||||
formValues: values,
|
||||
};
|
||||
|
||||
onSubmit(params);
|
||||
}
|
||||
|
||||
paymentForm(formRenderProps) {
|
||||
const {
|
||||
className,
|
||||
rootClassName,
|
||||
inProgress: submitInProgress,
|
||||
formId,
|
||||
intl,
|
||||
invalid,
|
||||
handleSubmit,
|
||||
addPaymentMethodError,
|
||||
deletePaymentMethodError,
|
||||
createStripeCustomerError,
|
||||
handleCardSetupError,
|
||||
form,
|
||||
} = formRenderProps;
|
||||
|
||||
this.finalFormAPI = form;
|
||||
const cardInputNeedsAttention = !this.state.cardValueValid;
|
||||
const submitDisabled = invalid || cardInputNeedsAttention || submitInProgress;
|
||||
const hasCardError = this.state.error && !submitInProgress;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const cardClasses = classNames(css.card, {
|
||||
[css.cardSuccess]: this.state.cardValueValid,
|
||||
[css.cardError]: hasCardError,
|
||||
});
|
||||
|
||||
const hasErrors =
|
||||
addPaymentMethodError ||
|
||||
deletePaymentMethodError ||
|
||||
createStripeCustomerError ||
|
||||
handleCardSetupError;
|
||||
|
||||
const errorMessage = intl.formatMessage({ id: 'PaymentMethodsForm.genericError' });
|
||||
|
||||
const billingDetailsNameLabel = intl.formatMessage({
|
||||
id: 'PaymentMethodsForm.billingDetailsNameLabel',
|
||||
});
|
||||
|
||||
const billingDetailsNamePlaceholder = intl.formatMessage({
|
||||
id: 'PaymentMethodsForm.billingDetailsNamePlaceholder',
|
||||
});
|
||||
|
||||
const infoText = intl.formatMessage({
|
||||
id: 'PaymentMethodsForm.infoText',
|
||||
});
|
||||
|
||||
// Stripe recommends asking billing address.
|
||||
// In PaymentMethodsForm, we send name and email as billing details, but address only if it exists.
|
||||
const billingAddress = (
|
||||
<StripePaymentAddress intl={intl} form={form} fieldId={formId} card={this.card} />
|
||||
);
|
||||
|
||||
const hasStripeKey = config.stripe.publishableKey;
|
||||
|
||||
return hasStripeKey ? (
|
||||
<Form className={classes} onSubmit={handleSubmit}>
|
||||
<label className={css.paymentLabel} htmlFor={`${formId}-card`}>
|
||||
<FormattedMessage id="PaymentMethodsForm.paymentCardDetails" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={cardClasses}
|
||||
id={`${formId}-card`}
|
||||
ref={el => {
|
||||
this.cardContainer = el;
|
||||
}}
|
||||
/>
|
||||
<div className={css.infoText}>{infoText}</div>
|
||||
{hasCardError ? <span className={css.error}>{this.state.error}</span> : null}
|
||||
<div className={css.paymentAddressField}>
|
||||
<h3 className={css.billingHeading}>
|
||||
<FormattedMessage id="PaymentMethodsForm.billingDetails" />
|
||||
</h3>
|
||||
|
||||
<FieldTextInput
|
||||
className={css.field}
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="cc-name"
|
||||
label={billingDetailsNameLabel}
|
||||
placeholder={billingDetailsNamePlaceholder}
|
||||
/>
|
||||
|
||||
{billingAddress}
|
||||
</div>
|
||||
|
||||
<div className={css.submitContainer}>
|
||||
{hasErrors ? (
|
||||
<span className={css.errorMessage}>
|
||||
{hasErrors.message ? hasErrors.message : errorMessage}
|
||||
</span>
|
||||
) : null}
|
||||
<PrimaryButton
|
||||
className={css.submitButton}
|
||||
type="submit"
|
||||
inProgress={submitInProgress}
|
||||
disabled={submitDisabled}
|
||||
>
|
||||
<FormattedMessage id="PaymentMethodsForm.submitPaymentInfo" />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div className={css.missingStripeKey}>
|
||||
<FormattedMessage id="PaymentMethodsForm.missingStripeKey" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onSubmit, ...rest } = this.props;
|
||||
return <FinalForm onSubmit={this.handleSubmit} {...rest} render={this.paymentForm} />;
|
||||
}
|
||||
}
|
||||
|
||||
PaymentMethodsForm.defaultProps = {
|
||||
className: null,
|
||||
rootClassName: null,
|
||||
inProgress: false,
|
||||
handleSubmit: null,
|
||||
invalid: false,
|
||||
addPaymentMethodError: null,
|
||||
deletePaymentMethodError: null,
|
||||
createStripeCustomerError: null,
|
||||
handleCardSetupError: null,
|
||||
form: null,
|
||||
};
|
||||
|
||||
PaymentMethodsForm.propTypes = {
|
||||
formId: string,
|
||||
intl: intlShape.isRequired,
|
||||
invalid: bool,
|
||||
handleSubmit: func,
|
||||
addPaymentMethodError: object,
|
||||
deletePaymentMethodError: object,
|
||||
createStripeCustomerError: object,
|
||||
handleCardSetupError: object,
|
||||
form: object,
|
||||
};
|
||||
|
||||
export default injectIntl(PaymentMethodsForm);
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
border-bottom-color: var(--attentionColor);
|
||||
|
||||
@media (--viewportMedium) {
|
||||
height: 35px;
|
||||
height: 38px;
|
||||
}
|
||||
@media (--viewportLarge) {
|
||||
height: 38px;
|
||||
|
|
@ -42,26 +42,80 @@
|
|||
}
|
||||
|
||||
.paymentHeading {
|
||||
margin: 0 0 14px 0;
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
padding-top: 8px;
|
||||
padding-bottom: 0px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 0 0 26px 0;
|
||||
margin: 0 0 16px 0;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.paymentMethodSelector {
|
||||
margin-bottom: 36px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.saveForLaterUse {
|
||||
padding-top: 6px;
|
||||
margin-bottom: 36px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
padding-top: 8px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.saveForLaterUseCheckbox {
|
||||
display: inline-block;
|
||||
padding: 2px 0 3px 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
padding: 1px 0 0px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.saveForLaterUseLabel {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
color: var(--matterColor);
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.saveForLaterUseLegalInfo {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
color: var(--matterColorAnti);
|
||||
display: inline-block;
|
||||
padding: 0px 0 0px 26px;
|
||||
margin-top: -2px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: -1px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.billingHeading {
|
||||
margin: 0 0 14px 0;
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
padding-top: 7px;
|
||||
padding-bottom: 1px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 0 0 26px 0;
|
||||
margin: 0 0 24px 0;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,25 +123,22 @@
|
|||
margin: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@media (--viewportLarge) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.messageHeading {
|
||||
color: var(--matterColorAnti);
|
||||
margin: 40px 0 14px 0;
|
||||
margin: 36px 0 12px 0;
|
||||
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
margin: 41px 0 26px 0;
|
||||
}
|
||||
@media (--viewportLarge) {
|
||||
margin: 40px 0 26px 0;
|
||||
margin: 24px 0 24px 0;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,29 +194,3 @@
|
|||
.missingStripeKey {
|
||||
color: var(--failColor);
|
||||
}
|
||||
|
||||
.paymentAddressField {
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.postalCode {
|
||||
margin-top: 24px;
|
||||
width: calc(40% - 9px);
|
||||
}
|
||||
|
||||
.city {
|
||||
margin-top: 24px;
|
||||
width: calc(60% - 9px);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,18 @@ import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
|||
import { Form as FinalForm } from 'react-final-form';
|
||||
import classNames from 'classnames';
|
||||
import config from '../../config';
|
||||
import { Form, PrimaryButton, FieldTextInput } from '../../components';
|
||||
import StripePaymentAddress from './StripePaymentAddress';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { ensurePaymentMethodCard } from '../../util/data';
|
||||
|
||||
import {
|
||||
Form,
|
||||
PrimaryButton,
|
||||
FieldCheckbox,
|
||||
FieldTextInput,
|
||||
IconSpinner,
|
||||
SavedCardDetails,
|
||||
StripePaymentAddress,
|
||||
} from '../../components';
|
||||
import css from './StripePaymentForm.css';
|
||||
|
||||
/**
|
||||
|
|
@ -75,9 +85,92 @@ const cardStyles = {
|
|||
},
|
||||
};
|
||||
|
||||
const OneTimePaymentWithCardElement = props => {
|
||||
const { cardClasses, formId, handleStripeElementRef, hasCardError, error, label, intl } = props;
|
||||
const labelText =
|
||||
label || intl.formatMessage({ id: 'StripePaymentForm.saveAfterOnetimePayment' });
|
||||
return (
|
||||
<React.Fragment>
|
||||
<label className={css.paymentLabel} htmlFor={`${formId}-card`}>
|
||||
<FormattedMessage id="StripePaymentForm.paymentCardDetails" />
|
||||
</label>
|
||||
<div className={cardClasses} id={`${formId}-card`} ref={handleStripeElementRef} />
|
||||
{hasCardError ? <span className={css.error}>{error}</span> : null}
|
||||
<div className={css.saveForLaterUse}>
|
||||
<FieldCheckbox
|
||||
className={css.saveForLaterUseCheckbox}
|
||||
textClassName={css.saveForLaterUseLabel}
|
||||
id="saveAfterOnetimePayment"
|
||||
name="saveAfterOnetimePayment"
|
||||
label={labelText}
|
||||
value="saveAfterOnetimePayment"
|
||||
useSuccessColor
|
||||
/>
|
||||
<span className={css.saveForLaterUseLegalInfo}>
|
||||
<FormattedMessage id="StripePaymentForm.saveforLaterUseLegalInfo" />
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const PaymentMethodSelector = props => {
|
||||
const {
|
||||
cardClasses,
|
||||
formId,
|
||||
changePaymentMethod,
|
||||
defaultPaymentMethod,
|
||||
handleStripeElementRef,
|
||||
hasCardError,
|
||||
error,
|
||||
paymentMethod,
|
||||
intl,
|
||||
} = props;
|
||||
const last4Digits = defaultPaymentMethod.attributes.card.last4Digits;
|
||||
const labelText = intl.formatMessage(
|
||||
{ id: 'StripePaymentForm.replaceAfterOnetimePayment' },
|
||||
{ last4Digits }
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h3 className={css.paymentHeading}>
|
||||
<FormattedMessage id="StripePaymentForm.payWithHeading" />
|
||||
</h3>
|
||||
<SavedCardDetails
|
||||
className={css.paymentMethodSelector}
|
||||
card={defaultPaymentMethod.attributes.card}
|
||||
onChange={changePaymentMethod}
|
||||
/>
|
||||
{paymentMethod === 'replaceCard' ? (
|
||||
<OneTimePaymentWithCardElement
|
||||
cardClasses={cardClasses}
|
||||
formId={formId}
|
||||
handleStripeElementRef={handleStripeElementRef}
|
||||
hasCardError={hasCardError}
|
||||
error={error}
|
||||
label={labelText}
|
||||
intl={intl}
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentMethod = (selectedPaymentMethod, hasDefaultPaymentMethod) => {
|
||||
return selectedPaymentMethod == null && hasDefaultPaymentMethod
|
||||
? 'defaultCard'
|
||||
: selectedPaymentMethod == null
|
||||
? 'onetimeCardPayment'
|
||||
: selectedPaymentMethod;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
error: null,
|
||||
cardValueValid: false,
|
||||
// The mode can be 'onetimePayment', 'defaultCard', or 'replaceCard'
|
||||
// Check SavedCardDetails component for more information
|
||||
paymentMethod: null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -97,39 +190,78 @@ class StripePaymentForm extends Component {
|
|||
this.handleCardValueChange = this.handleCardValueChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.paymentForm = this.paymentForm.bind(this);
|
||||
this.initializeStripeElement = this.initializeStripeElement.bind(this);
|
||||
this.handleStripeElementRef = this.handleStripeElementRef.bind(this);
|
||||
this.changePaymentMethod = this.changePaymentMethod.bind(this);
|
||||
this.finalFormAPI = null;
|
||||
this.cardContainer = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!window.Stripe) {
|
||||
throw new Error('Stripe must be loaded for StripePaymentForm');
|
||||
}
|
||||
|
||||
if (config.stripe.publishableKey) {
|
||||
const {
|
||||
onStripeInitialized,
|
||||
hasHandledCardPayment,
|
||||
defaultPaymentMethod,
|
||||
loadingData,
|
||||
} = this.props;
|
||||
this.stripe = window.Stripe(config.stripe.publishableKey);
|
||||
this.props.onStripeInitialized(this.stripe);
|
||||
onStripeInitialized(this.stripe);
|
||||
|
||||
if (!this.props.hasHandledCardPayment) {
|
||||
const elements = this.stripe.elements(stripeElementsOptions);
|
||||
this.card = elements.create('card', { style: cardStyles });
|
||||
this.card.mount(this.cardContainer);
|
||||
this.card.addEventListener('change', this.handleCardValueChange);
|
||||
// EventListener is the only way to simulate breakpoints with Stripe.
|
||||
window.addEventListener('resize', () => {
|
||||
if (!(hasHandledCardPayment || defaultPaymentMethod || loadingData)) {
|
||||
this.initializeStripeElement();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.card) {
|
||||
this.card.removeEventListener('change', this.handleCardValueChange);
|
||||
this.card.unmount();
|
||||
this.card = null;
|
||||
}
|
||||
}
|
||||
|
||||
initializeStripeElement(element) {
|
||||
const elements = this.stripe.elements(stripeElementsOptions);
|
||||
|
||||
if (!this.card) {
|
||||
this.card = elements.create('card', { style: cardStyles });
|
||||
this.card.mount(element || this.cardContainer);
|
||||
this.card.addEventListener('change', this.handleCardValueChange);
|
||||
// EventListener is the only way to simulate breakpoints with Stripe.
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.card) {
|
||||
if (window.innerWidth < 1024) {
|
||||
this.card.update({ style: { base: { fontSize: '18px', lineHeight: '24px' } } });
|
||||
} else {
|
||||
this.card.update({ style: { base: { fontSize: '20px', lineHeight: '32px' } } });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.card) {
|
||||
|
||||
changePaymentMethod(changedTo) {
|
||||
if (this.card && changedTo === 'defaultCard') {
|
||||
this.card.removeEventListener('change', this.handleCardValueChange);
|
||||
this.card.unmount();
|
||||
this.card = null;
|
||||
}
|
||||
this.setState({ paymentMethod: changedTo });
|
||||
}
|
||||
|
||||
handleStripeElementRef(el) {
|
||||
this.cardContainer = el;
|
||||
if (this.stripe && el) {
|
||||
this.initializeStripeElement(el);
|
||||
}
|
||||
}
|
||||
|
||||
handleCardValueChange(event) {
|
||||
const { intl } = this.props;
|
||||
const { error, complete } = event;
|
||||
|
|
@ -147,11 +279,19 @@ class StripePaymentForm extends Component {
|
|||
});
|
||||
}
|
||||
handleSubmit(values) {
|
||||
const { onSubmit, inProgress, formId, hasHandledCardPayment } = this.props;
|
||||
const {
|
||||
onSubmit,
|
||||
inProgress,
|
||||
formId,
|
||||
hasHandledCardPayment,
|
||||
defaultPaymentMethod,
|
||||
} = this.props;
|
||||
const { initialMessage } = values;
|
||||
const cardInputNeedsAttention = !(hasHandledCardPayment || this.state.cardValueValid);
|
||||
const { cardValueValid, paymentMethod } = this.state;
|
||||
const billingDetailsKnown = hasHandledCardPayment || defaultPaymentMethod;
|
||||
const onetimePaymentNeedsAttention = !billingDetailsKnown && !cardValueValid;
|
||||
|
||||
if (inProgress || cardInputNeedsAttention) {
|
||||
if (inProgress || onetimePaymentNeedsAttention) {
|
||||
// Already submitting or card value incomplete/invalid
|
||||
return;
|
||||
}
|
||||
|
|
@ -161,6 +301,10 @@ class StripePaymentForm extends Component {
|
|||
card: this.card,
|
||||
formId,
|
||||
formValues: values,
|
||||
paymentMethod: getPaymentMethod(
|
||||
paymentMethod,
|
||||
ensurePaymentMethodCard(defaultPaymentMethod).id
|
||||
),
|
||||
};
|
||||
onSubmit(params);
|
||||
}
|
||||
|
|
@ -170,6 +314,7 @@ class StripePaymentForm extends Component {
|
|||
className,
|
||||
rootClassName,
|
||||
inProgress: submitInProgress,
|
||||
loadingData,
|
||||
formId,
|
||||
paymentInfo,
|
||||
authorDisplayName,
|
||||
|
|
@ -182,12 +327,16 @@ class StripePaymentForm extends Component {
|
|||
handleSubmit,
|
||||
form,
|
||||
hasHandledCardPayment,
|
||||
defaultPaymentMethod,
|
||||
} = formRenderProps;
|
||||
|
||||
this.finalFormAPI = form;
|
||||
const billingDetailsNeeded = !(confirmPaymentError || hasHandledCardPayment);
|
||||
const cardInputNeedsAttention = !(hasHandledCardPayment || this.state.cardValueValid);
|
||||
const submitDisabled = invalid || cardInputNeedsAttention || submitInProgress;
|
||||
|
||||
const ensuredDefaultPaymentMethod = ensurePaymentMethodCard(defaultPaymentMethod);
|
||||
const billingDetailsNeeded = !(hasHandledCardPayment || confirmPaymentError);
|
||||
const billingDetailsKnown = hasHandledCardPayment || ensuredDefaultPaymentMethod;
|
||||
const onetimePaymentNeedsAttention = !billingDetailsKnown && !this.state.cardValueValid;
|
||||
const submitDisabled = invalid || onetimePaymentNeedsAttention || submitInProgress;
|
||||
const hasCardError = this.state.error && !submitInProgress;
|
||||
const hasPaymentErrors = handleCardPaymentError || confirmPaymentError;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
|
|
@ -238,44 +387,70 @@ class StripePaymentForm extends Component {
|
|||
);
|
||||
|
||||
const hasStripeKey = config.stripe.publishableKey;
|
||||
|
||||
const showPaymentMethodSelector = ensuredDefaultPaymentMethod.id;
|
||||
const selectedPaymentMethod = getPaymentMethod(
|
||||
this.state.paymentMethod,
|
||||
showPaymentMethodSelector
|
||||
);
|
||||
const showOnetimePaymentFields = ['onetimeCardPayment', 'replaceCard'].includes(
|
||||
selectedPaymentMethod
|
||||
);
|
||||
return hasStripeKey ? (
|
||||
<Form className={classes} onSubmit={handleSubmit}>
|
||||
{billingDetailsNeeded ? (
|
||||
{billingDetailsNeeded && !loadingData ? (
|
||||
<React.Fragment>
|
||||
<h3 className={css.paymentHeading}>
|
||||
<FormattedMessage id="StripePaymentForm.paymentHeading" />
|
||||
</h3>
|
||||
<label className={css.paymentLabel} htmlFor={`${formId}-card`}>
|
||||
<FormattedMessage id="StripePaymentForm.creditCardDetails" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={cardClasses}
|
||||
id={`${formId}-card`}
|
||||
ref={el => {
|
||||
this.cardContainer = el;
|
||||
}}
|
||||
/>
|
||||
{hasCardError ? <span className={css.error}>{this.state.error}</span> : null}
|
||||
<div className={css.paymentAddressField}>
|
||||
<h3 className={css.billingHeading}>
|
||||
<FormattedMessage id="StripePaymentForm.billingDetails" />
|
||||
</h3>
|
||||
|
||||
<FieldTextInput
|
||||
className={css.field}
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="cc-name"
|
||||
label={billingDetailsNameLabel}
|
||||
placeholder={billingDetailsNamePlaceholder}
|
||||
{showPaymentMethodSelector ? (
|
||||
<PaymentMethodSelector
|
||||
cardClasses={cardClasses}
|
||||
formId={formId}
|
||||
defaultPaymentMethod={ensuredDefaultPaymentMethod}
|
||||
changePaymentMethod={this.changePaymentMethod}
|
||||
handleStripeElementRef={this.handleStripeElementRef}
|
||||
hasCardError={hasCardError}
|
||||
error={this.state.error}
|
||||
paymentMethod={selectedPaymentMethod}
|
||||
intl={intl}
|
||||
/>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<h3 className={css.paymentHeading}>
|
||||
<FormattedMessage id="StripePaymentForm.paymentHeading" />
|
||||
</h3>
|
||||
<OneTimePaymentWithCardElement
|
||||
cardClasses={cardClasses}
|
||||
formId={formId}
|
||||
handleStripeElementRef={this.handleStripeElementRef}
|
||||
hasCardError={hasCardError}
|
||||
error={this.state.error}
|
||||
intl={intl}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{billingAddress}
|
||||
</div>
|
||||
{showOnetimePaymentFields ? (
|
||||
<div className={css.paymentAddressField}>
|
||||
<h3 className={css.billingHeading}>
|
||||
<FormattedMessage id="StripePaymentForm.billingDetails" />
|
||||
</h3>
|
||||
|
||||
<FieldTextInput
|
||||
className={css.field}
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="cc-name"
|
||||
label={billingDetailsNameLabel}
|
||||
placeholder={billingDetailsNamePlaceholder}
|
||||
/>
|
||||
|
||||
{billingAddress}
|
||||
</div>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
) : loadingData ? (
|
||||
<p className={css.spinner}>
|
||||
<IconSpinner />
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{initiateOrderError ? (
|
||||
|
|
@ -333,8 +508,10 @@ StripePaymentForm.defaultProps = {
|
|||
className: null,
|
||||
rootClassName: null,
|
||||
inProgress: false,
|
||||
loadingData: false,
|
||||
showInitialMessageInput: true,
|
||||
hasHandledCardPayment: false,
|
||||
defaultPaymentMethod: null,
|
||||
initiateOrderError: null,
|
||||
handleCardPaymentError: null,
|
||||
confirmPaymentError: null,
|
||||
|
|
@ -344,6 +521,7 @@ StripePaymentForm.propTypes = {
|
|||
className: string,
|
||||
rootClassName: string,
|
||||
inProgress: bool,
|
||||
loadingData: bool,
|
||||
initiateOrderError: object,
|
||||
handleCardPaymentError: object,
|
||||
confirmPaymentError: object,
|
||||
|
|
@ -354,6 +532,7 @@ StripePaymentForm.propTypes = {
|
|||
authorDisplayName: string.isRequired,
|
||||
showInitialMessageInput: bool,
|
||||
hasHandledCardPayment: bool,
|
||||
defaultPaymentMethod: propTypes.defaultPaymentMethod,
|
||||
};
|
||||
|
||||
export default injectIntl(StripePaymentForm);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export { default as LoginForm } from './LoginForm/LoginForm';
|
|||
export { default as PasswordChangeForm } from './PasswordChangeForm/PasswordChangeForm';
|
||||
export { default as PasswordRecoveryForm } from './PasswordRecoveryForm/PasswordRecoveryForm';
|
||||
export { default as PasswordResetForm } from './PasswordResetForm/PasswordResetForm';
|
||||
export { default as PaymentMethodsForm } from './PaymentMethodsForm/PaymentMethodsForm';
|
||||
export { default as PayoutDetailsForm } from './PayoutDetailsForm/PayoutDetailsForm';
|
||||
export { default as PriceFilterForm } from './PriceFilterForm/PriceFilterForm';
|
||||
export { default as ProfileSettingsForm } from './ProfileSettingsForm/ProfileSettingsForm';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
PasswordRecoveryPage,
|
||||
PasswordResetPage,
|
||||
PayoutPreferencesPage,
|
||||
PaymentMethodsPage,
|
||||
PrivacyPolicyPage,
|
||||
ProfilePage,
|
||||
ProfileSettingsPage,
|
||||
|
|
@ -33,6 +34,7 @@ export const ACCOUNT_SETTINGS_PAGES = [
|
|||
'ContactDetailsPage',
|
||||
'PasswordChangePage',
|
||||
'PayoutPreferencesPage',
|
||||
'PaymentMethodsPage',
|
||||
];
|
||||
|
||||
// https://en.wikipedia.org/wiki/Universally_unique_identifier#Nil_UUID
|
||||
|
|
@ -249,6 +251,14 @@ const routeConfiguration = () => {
|
|||
component: props => <PayoutPreferencesPage {...props} />,
|
||||
loadData: PayoutPreferencesPage.loadData,
|
||||
},
|
||||
{
|
||||
path: '/account/payment-methods',
|
||||
name: 'PaymentMethodsPage',
|
||||
auth: true,
|
||||
authPage: 'LoginPage',
|
||||
component: props => <PaymentMethodsPage {...props} />,
|
||||
loadData: PaymentMethodsPage.loadData,
|
||||
},
|
||||
{
|
||||
path: '/terms-of-service',
|
||||
name: 'TermsOfServicePage',
|
||||
|
|
|
|||
|
|
@ -773,13 +773,13 @@
|
|||
"StripeBankAccountTokenInputField.transitNumber.placeholder": "Transit Number eingeben…",
|
||||
"StripeBankAccountTokenInputField.transitNumber.required": "Transit Number wird benötigt",
|
||||
"StripeBankAccountTokenInputField.unsupportedCountry": "Nicht unterstützes Land: {country}",
|
||||
"StripePaymentForm.creditCardDetails": "Kreditkartendetails",
|
||||
"StripePaymentForm.genericError": "Zahlungsinformationen konnten nicht verarbeitet werden. Bitte erneut versuchen.",
|
||||
"StripePaymentForm.messageHeading": "Nachricht",
|
||||
"StripePaymentForm.messageLabel": "Hallo an deine(n) Gastgeber(in) {messageOptionalText} sagen",
|
||||
"StripePaymentForm.messageOptionalText": "• optional",
|
||||
"StripePaymentForm.messagePlaceholder": "Hallo {name}! Ich freue mich auf…",
|
||||
"StripePaymentForm.missingStripeKey": "Der öffentliche Stripe API-schlüssel (\"Stripe publishable key\") wurde noch nicht für diesen Marktplatz konfiguriert. Leider kann der Eintrag nicht gebucht werden.",
|
||||
"StripePaymentForm.paymentCardDetails": "Kreditkartendetails",
|
||||
"StripePaymentForm.paymentHeading": "Zahlung",
|
||||
"StripePaymentForm.stripe.api_connection_error": "Fehler bei Verbindung mit Stripe API.",
|
||||
"StripePaymentForm.stripe.api_error": "Fehler bei Stripe API.",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@
|
|||
"CheckoutPage.initiateOrderError": "Payment request failed. Please go back to {listingLink} and try again. If the error persists, try refreshing the page or contacting the marketplace administrator.",
|
||||
"CheckoutPage.initiateOrderStripeError": "The payment processor gave the following errors: {stripeErrors}",
|
||||
"CheckoutPage.listingNotFoundError": "Unfortunately, the listing is no longer available.",
|
||||
"CheckoutPage.loadingData": "Loading checkout data…",
|
||||
"CheckoutPage.paymentExpiredMessage": "Payment was not completed in 15 minutes. Please go back to {listingLink} and try again.",
|
||||
"CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.",
|
||||
"CheckoutPage.perDay": "per day",
|
||||
|
|
@ -123,10 +122,7 @@
|
|||
"ContactDetailsForm.resendEmailVerificationText": "Resend verification email.",
|
||||
"ContactDetailsForm.saveChanges": "Save changes",
|
||||
"ContactDetailsForm.tooManyVerificationRequests": "Too many email verification requests sent.",
|
||||
"ContactDetailsPage.contactDetailsTabTitle": "Contact details",
|
||||
"ContactDetailsPage.heading": "Contact details",
|
||||
"ContactDetailsPage.passwordTabTitle": "Password",
|
||||
"ContactDetailsPage.paymentsTabTitle": "Payments",
|
||||
"ContactDetailsPage.title": "Contact details",
|
||||
"CookieConsent.continue": "Continue",
|
||||
"CookieConsent.cookieLink": "cookies",
|
||||
|
|
@ -318,6 +314,10 @@
|
|||
"KeywordFilterPlainForm.placeholder": "Enter a keyword…",
|
||||
"LandingPage.schemaDescription": "Book a sauna using Saunatime or earn some income by sharing your sauna",
|
||||
"LandingPage.schemaTitle": "Book saunas everywhere | {siteTitle}",
|
||||
"LayoutWrapperAccountSettingsSideNav.contactDetailsTabTitle": "Contact details",
|
||||
"LayoutWrapperAccountSettingsSideNav.passwordTabTitle": "Password",
|
||||
"LayoutWrapperAccountSettingsSideNav.paymentsTabTitle": "Payout details",
|
||||
"LayoutWrapperAccountSettingsSideNav.paymentMethodsTabTitle": "Payment methods",
|
||||
"ListingCard.hostedBy": "Hosted by {authorName}.",
|
||||
"ListingCard.perDay": "per day",
|
||||
"ListingCard.perNight": "per night",
|
||||
|
|
@ -426,10 +426,7 @@
|
|||
"PasswordChangeForm.passwordTooLong": "The password should be at most {maxLength} characters",
|
||||
"PasswordChangeForm.passwordTooShort": "The password should be at least {minLength} characters",
|
||||
"PasswordChangeForm.saveChanges": "Save changes",
|
||||
"PasswordChangePage.contactDetailsTabTitle": "Contact details",
|
||||
"PasswordChangePage.heading": "Password settings",
|
||||
"PasswordChangePage.passwordTabTitle": "Password",
|
||||
"PasswordChangePage.paymentsTabTitle": "Payments",
|
||||
"PasswordChangePage.title": "Password",
|
||||
"PasswordRecoveryForm.emailInvalid": "A valid email address is required",
|
||||
"PasswordRecoveryForm.emailLabel": "Email",
|
||||
|
|
@ -466,6 +463,21 @@
|
|||
"PasswordResetPage.recoveryLinkText": "password recovery page",
|
||||
"PasswordResetPage.resetFailed": "Reset failed. Please try again.",
|
||||
"PasswordResetPage.title": "Reset password",
|
||||
"PaymentMethodsForm.billingDetails": "Billing details",
|
||||
"PaymentMethodsForm.billingDetailsNameLabel": "Card holder's name",
|
||||
"PaymentMethodsForm.billingDetailsNamePlaceholder": "Enter your name…",
|
||||
"PaymentMethodsForm.paymentCardDetails": "Payment card details",
|
||||
"PaymentMethodsForm.genericError": "Could not handle payment data. Please try again.",
|
||||
"PaymentMethodsForm.handleCardPaymentError": "We are unable to authenticate your payment method. Please check your authentication details and try again.",
|
||||
"PaymentMethodsForm.infoText": "I authorise Saunatime to send instructions to the financial institution that issued my card to take payments from my card account in accordance with the terms of my agreement with you.",
|
||||
"PaymentMethodsForm.paymentHeading": "Payment",
|
||||
"PaymentMethodsPage.deletePaymentMethod": "Delete card",
|
||||
"PaymentMethodsPage.heading": "Payment methods",
|
||||
"PaymentMethodsPage.loadingData": "Loading data…",
|
||||
"PaymentMethodsPage.savedPaymentMethodTitle": "Credit card details",
|
||||
"PaymentMethodsPage.savedPaymentMethodPlaceholder": "•••• •••• •••• {lastFour}",
|
||||
"PaymentMethodsForm.submitPaymentInfo": "Save payment card",
|
||||
"PaymentMethodsPage.title": "Payment methods",
|
||||
"PayoutDetailsForm.accountOpenerInfoText": "Account opener needs to be an individual with authorization to sign on behalf of the organization.",
|
||||
"PayoutDetailsForm.accountOpenerTitle": "Account opener",
|
||||
"PayoutDetailsForm.accountTypeTitle": "Account type",
|
||||
|
|
@ -616,16 +628,13 @@
|
|||
"PayoutDetailsForm.stripeToSText": "By saving details, you agree to the {stripeConnectedAccountTermsLink}",
|
||||
"PayoutDetailsForm.submitButtonText": "Save details & publish listing",
|
||||
"PayoutDetailsForm.title": "One more thing: payout preferences",
|
||||
"PayoutPreferencesPage.contactDetailsTabTitle": "Contact details",
|
||||
"PayoutPreferencesPage.heading": "Payment settings",
|
||||
"PayoutPreferencesPage.heading": "Payout details",
|
||||
"PayoutPreferencesPage.loadingData": "Loading data…",
|
||||
"PayoutPreferencesPage.passwordTabTitle": "Password",
|
||||
"PayoutPreferencesPage.paymentsTabTitle": "Payments",
|
||||
"PayoutPreferencesPage.payoutDetailsSaved": "Payment information successfully saved! If you want to change your payment details, please contact the marketplace admins.",
|
||||
"PayoutPreferencesPage.stripeAlreadyConnected": "You’ve already entered your payment details. If you want to change your payment details, please contact the marketplace admins.",
|
||||
"PayoutPreferencesPage.stripeNotConnected": "Payment information not saved. Please fill in the form to accept payments from your listings.",
|
||||
"PayoutPreferencesPage.submitButtonText": "Save details",
|
||||
"PayoutPreferencesPage.title": "Payment settings",
|
||||
"PayoutPreferencesPage.title": "Payout details",
|
||||
"PriceFilter.clear": "Clear",
|
||||
"PriceFilter.label": "Price",
|
||||
"PriceFilter.labelSelectedPlain": "Price: {minPrice} - {maxPrice}",
|
||||
|
|
@ -684,6 +693,16 @@
|
|||
"ReviewModal.description": "Reviews are an important part of the Saunatime community. Please share what went well and what could have been improved.",
|
||||
"ReviewModal.later": "Later",
|
||||
"ReviewModal.title": "Leave a review for {revieweeName}",
|
||||
"SavedCardDetails.cancel": "Cancel",
|
||||
"SavedCardDetails.deletePaymentMethod": "Remove payment card",
|
||||
"SavedCardDetails.expiredCardText": "This payment card has expired",
|
||||
"SavedCardDetails.removeCard": "Remove card",
|
||||
"SavedCardDetails.removeCardModalContent": "The card will be removed from your account and can't be used for future purchases.",
|
||||
"SavedCardDetails.removeCardModalTitle": "Are you sure you want to remove this payment card?",
|
||||
"SavedCardDetails.replaceCardText": "Credit or debit card",
|
||||
"SavedCardDetails.replaceCardTitle": "Use another payment method",
|
||||
"SavedCardDetails.savedPaymentMethodPlaceholderDesktop": "•••• •••• •••• {lastFour}",
|
||||
"SavedCardDetails.savedPaymentMethodPlaceholderMobile": "…{lastFour}",
|
||||
"SearchFilters.amenitiesLabel": "Amenities",
|
||||
"SearchFilters.categoryLabel": "Category",
|
||||
"SearchFilters.filtersButtonLabel": "Filters",
|
||||
|
|
@ -797,7 +816,7 @@
|
|||
"StripePaymentAddress.addressLine1Label": "Street address",
|
||||
"StripePaymentAddress.addressLine1Placeholder": "123 Example Street",
|
||||
"StripePaymentAddress.addressLine1Required": "Street address is required",
|
||||
"StripePaymentAddress.addressLine2Label": "Apt, suite, building # {optionalText}",
|
||||
"StripePaymentAddress.addressLine2Label": "Apt # {optionalText}",
|
||||
"StripePaymentAddress.addressLine2Placeholder": "A 42",
|
||||
"StripePaymentAddress.cityLabel": "City",
|
||||
"StripePaymentAddress.cityPlaceholder": "Helsinki",
|
||||
|
|
@ -815,7 +834,6 @@
|
|||
"StripePaymentForm.billingDetailsNameLabel": "Card holder's name",
|
||||
"StripePaymentForm.billingDetailsNamePlaceholder": "Enter your name…",
|
||||
"StripePaymentForm.confirmPaymentError": "Payment has been made but we were unable to confirm the booking. Please try to confirm the booking request again! If the booking is not confirmed in time, the payment will be fully refunded.",
|
||||
"StripePaymentForm.creditCardDetails": "Credit card details",
|
||||
"StripePaymentForm.genericError": "Could not handle payment data. Please try again.",
|
||||
"StripePaymentForm.handleCardPaymentError": "We are unable to authenticate your payment method. Please check your authentication details and try again.",
|
||||
"StripePaymentForm.messageHeading": "Message",
|
||||
|
|
@ -823,7 +841,12 @@
|
|||
"StripePaymentForm.messageOptionalText": "• optional",
|
||||
"StripePaymentForm.messagePlaceholder": "Hello {name}! I'm looking forward to…",
|
||||
"StripePaymentForm.missingStripeKey": "Stripe publishable key has not been configured to this marketplace. Unfortunately, booking the listing is not possible.",
|
||||
"StripePaymentForm.paymentCardDetails": "Payment card details",
|
||||
"StripePaymentForm.paymentHeading": "Payment",
|
||||
"StripePaymentForm.payWithHeading": "Pay with",
|
||||
"StripePaymentForm.replaceAfterOnetimePayment": "Replace card …{last4Digits} with the new card for future bookings",
|
||||
"StripePaymentForm.saveAfterOnetimePayment": "Save card details for future bookings",
|
||||
"StripePaymentForm.saveforLaterUseLegalInfo": "I authorise Saunatime to send instructions to the financial institution that issued my card to take payments from my card account in accordance with the terms of my agreement with you.",
|
||||
"StripePaymentForm.stripe.api_connection_error": "Could not connect to Stripe API.",
|
||||
"StripePaymentForm.stripe.api_error": "Error in Stripe API.",
|
||||
"StripePaymentForm.stripe.authentication_error": "Could not authenticate with Stripe.",
|
||||
|
|
@ -906,6 +929,7 @@
|
|||
"TransactionPanel.orderPreauthorizedInfo": "{providerName} has been notified about the booking request. Sit back and relax.",
|
||||
"TransactionPanel.orderPreauthorizedSubtitle": "You have requested to book {listingLink}.",
|
||||
"TransactionPanel.orderPreauthorizedTitle": "Great success, {customerName}!",
|
||||
"TransactionPanel.paymentMethodsPageLink": "payment methods page",
|
||||
"TransactionPanel.perDay": "per day",
|
||||
"TransactionPanel.perNight": "per night",
|
||||
"TransactionPanel.perUnit": "per unit",
|
||||
|
|
@ -920,6 +944,7 @@
|
|||
"TransactionPanel.salePaymentPendingTitle": "Payment for {listingLink} is not yet confirmed",
|
||||
"TransactionPanel.saleRequestedInfo": "{customerName} is waiting for your response.",
|
||||
"TransactionPanel.saleRequestedTitle": "{customerName} has requested to book {listingLink}.",
|
||||
"TransactionPanel.savePaymentMethodFailed": "Saving default payment method failed. Go to {paymentMethodsPageLink} to try again.",
|
||||
"TransactionPanel.sendingMessageNotAllowed": "This user has been removed. Sending message to the user is not possible anymore.",
|
||||
"TransactionPanel.sendMessagePlaceholder": "Send a message to {name}…",
|
||||
"UserCard.contactUser": "Contact",
|
||||
|
|
|
|||
|
|
@ -815,7 +815,6 @@
|
|||
"StripePaymentForm.billingDetailsNameLabel": "Nombre del titular de la tarjeta",
|
||||
"StripePaymentForm.billingDetailsNamePlaceholder": "Escribe el nombre…",
|
||||
"StripePaymentForm.confirmPaymentError": "El pago ha sido realizado pero no hemos podido confirmar la reserva. Por favor intenta confirmar la solicitud de reserva otra vez! Si la reserva no se confirma a tiempo, el pago será reembolsado en su totalidad.",
|
||||
"StripePaymentForm.creditCardDetails": "Datos de tarjeta de crédito",
|
||||
"StripePaymentForm.genericError": "Ups, ha habido algún problema con la información de pagos. Por favor, inténtalo de nuevo.",
|
||||
"StripePaymentForm.handleCardPaymentError": "No hemos podido autenticar el método de pago. Por favor checa los detalles de autenticación e intenta de nuevo.",
|
||||
"StripePaymentForm.messageHeading": "Mensaje",
|
||||
|
|
@ -823,6 +822,7 @@
|
|||
"StripePaymentForm.messageOptionalText": "• opcional",
|
||||
"StripePaymentForm.messagePlaceholder": "Hola {name}! Acabo de solicitar…",
|
||||
"StripePaymentForm.missingStripeKey": "La clave pública de Stripe no se ha configurado en este marketplace. Por esta razón, no es posible hacer reservas.",
|
||||
"StripePaymentForm.paymentCardDetails": "Datos de tarjeta de crédito",
|
||||
"StripePaymentForm.paymentHeading": "Pago",
|
||||
"StripePaymentForm.stripe.api_connection_error": "No se consigue conectar con API de Stripe.",
|
||||
"StripePaymentForm.stripe.api_error": "Error de conexión con Stripe.",
|
||||
|
|
|
|||
|
|
@ -815,7 +815,6 @@
|
|||
"StripePaymentForm.billingDetailsNameLabel": "Nom du porteur de la carte",
|
||||
"StripePaymentForm.billingDetailsNamePlaceholder": "Entrez votre nom…",
|
||||
"StripePaymentForm.confirmPaymentError": "Le paiement a été réalisé mais nous n'avons pu confirmer la réservation. Veuillez essayez de réserver de nouveau ! Si la réservation n'est pas confirmée dans les temps, le paiement sera entièrement remboursé.",
|
||||
"StripePaymentForm.creditCardDetails": "Votre carte bancaire",
|
||||
"StripePaymentForm.genericError": "Impossible de traiter ces détails de paiement. Veuillez essayer de nouveau.",
|
||||
"StripePaymentForm.handleCardPaymentError": "Nous n'avons pu authentifier votre méthode de paiement. Veuillez vérifier vos détails d'authentifications et essayer de nouveau.",
|
||||
"StripePaymentForm.messageHeading": "Message",
|
||||
|
|
@ -823,6 +822,7 @@
|
|||
"StripePaymentForm.messageOptionalText": "• optionel",
|
||||
"StripePaymentForm.messagePlaceholder": "Bonjour {name} ! J'aimerais…",
|
||||
"StripePaymentForm.missingStripeKey": "La \"Stripe publishable key\" n'a pas été configurée pour cette place de marché. Hélas, réserver cette annonce n'est pas possible pour le moment.",
|
||||
"StripePaymentForm.paymentCardDetails": "Votre carte bancaire",
|
||||
"StripePaymentForm.paymentHeading": "Paiement",
|
||||
"StripePaymentForm.stripe.api_connection_error": "Impossible de se connecter à l'API Stripe.",
|
||||
"StripePaymentForm.stripe.api_error": "Erreur avec l'API Stripe.",
|
||||
|
|
|
|||
|
|
@ -244,6 +244,37 @@ export const ensureAvailabilityException = availabilityException => {
|
|||
return { ...empty, ...availabilityException };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create shell objects to ensure that attributes etc. exists.
|
||||
*
|
||||
* @param {Object} stripeCustomer entity from API, which is to be ensured against null values
|
||||
*/
|
||||
export const ensureStripeCustomer = stripeCustomer => {
|
||||
const empty = { id: null, type: 'stripeCustomer', attributes: {} };
|
||||
return { ...empty, ...stripeCustomer };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create shell objects to ensure that attributes etc. exists.
|
||||
*
|
||||
* @param {Object} stripeCustomer entity from API, which is to be ensured against null values
|
||||
*/
|
||||
export const ensurePaymentMethodCard = stripePaymentMethod => {
|
||||
const empty = {
|
||||
id: null,
|
||||
type: 'stripePaymentMethod',
|
||||
attributes: { type: 'stripe-payment-method/card', card: {} },
|
||||
};
|
||||
const cardPaymentMethod = { ...empty, ...stripePaymentMethod };
|
||||
|
||||
if (cardPaymentMethod.attributes.type !== 'stripe-payment-method/card') {
|
||||
throw new Error(`'ensurePaymentMethodCard' got payment method with wrong type.
|
||||
'stripe-payment-method/card' was expected, received ${cardPaymentMethod.attributes.type}`);
|
||||
}
|
||||
|
||||
return cardPaymentMethod;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the display name of the given user as string. This function handles
|
||||
* missing data (e.g. when the user object is still being downloaded),
|
||||
|
|
|
|||
|
|
@ -308,6 +308,21 @@ propTypes.stripeAccount = shape({
|
|||
}),
|
||||
});
|
||||
|
||||
propTypes.defaultPaymentMethod = shape({
|
||||
id: propTypes.uuid.isRequired,
|
||||
type: propTypes.value('stripePaymentMethod').isRequired,
|
||||
attributes: shape({
|
||||
type: propTypes.value('stripe-payment-method/card').isRequired,
|
||||
stripePaymentMethodId: string.isRequired,
|
||||
card: shape({
|
||||
brand: string.isRequired,
|
||||
expirationMonth: number.isRequired,
|
||||
expirationYear: number.isRequired,
|
||||
last4Digits: string.isRequired,
|
||||
}).isRequired,
|
||||
}),
|
||||
});
|
||||
|
||||
export const LINE_ITEM_NIGHT = 'line-item/night';
|
||||
export const LINE_ITEM_DAY = 'line-item/day';
|
||||
export const LINE_ITEM_UNITS = 'line-item/units';
|
||||
|
|
|
|||
14
yarn.lock
14
yarn.lock
|
|
@ -6375,9 +6375,9 @@ jest@24.7.1:
|
|||
jest-cli "^24.7.1"
|
||||
|
||||
js-cookie@^2.1.3:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.0.tgz#1b2c279a6eece380a12168b92485265b35b1effb"
|
||||
integrity sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s=
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"
|
||||
integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==
|
||||
|
||||
js-levenshtein@^1.1.3:
|
||||
version "1.1.6"
|
||||
|
|
@ -9999,10 +9999,10 @@ shallowequal@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
|
||||
integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==
|
||||
|
||||
sharetribe-flex-sdk@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.4.1.tgz#abf1af12d34a31e05abb0121cd55f2f0716c46c1"
|
||||
integrity sha512-QcsEoOf2SdTW2NwkNbpaAKhT5P8s640dGZg9NPuzz5NaSESgl+gkkeHNIDRHnycyvINvl6IjGPmUFTPHYA1XkA==
|
||||
sharetribe-flex-sdk@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.5.0.tgz#a1bb9c603f8a52a1f53053cb610fdf15ff223288"
|
||||
integrity sha512-l/3vX9GEz9Uz9z+9+WDHH6/O1GMSh8sa1kQpEHBjypx3kDjFT2a6SSxEzk3YQ/qIaUEHvGj9G+Xy3AszF6pCrA==
|
||||
dependencies:
|
||||
axios "^0.19.0"
|
||||
js-cookie "^2.1.3"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue