Use the speculative API to get the transaction for the breakdown

This commit is contained in:
Kimmo Puputti 2017-09-01 14:33:05 +03:00
parent 5384c8530a
commit 8fe4aa2bce
6 changed files with 287 additions and 308 deletions

View file

@ -5,6 +5,10 @@
--logoHeightDesktop: 27px;
}
.loading {
margin: 24px;
}
/* Dummy Topbar */
.topbar {
@ -250,6 +254,14 @@
color: var(--failColor);
}
.speculateError {
color: var(--failColor);
@media (--viewportLarge) {
margin: 0 48px;
}
}
.paymentForm {
flex-grow: 1;
}

View file

@ -1,4 +1,6 @@
import { pick } from 'lodash';
import { updatedEntities, denormalisedEntities } from '../../util/data';
import * as propTypes from '../../util/propTypes';
// ================ Action types ================ //
@ -8,25 +10,54 @@ export const INITIATE_ORDER_REQUEST = 'app/CheckoutPage/INITIATE_ORDER_REQUEST';
export const INITIATE_ORDER_SUCCESS = 'app/CheckoutPage/INITIATE_ORDER_SUCCESS';
export const INITIATE_ORDER_ERROR = 'app/CheckoutPage/INITIATE_ORDER_ERROR';
export const SPECULATE_TRANSACTION_REQUEST = 'app/ListingPage/SPECULATE_TRANSACTION_REQUEST';
export const SPECULATE_TRANSACTION_SUCCESS = 'app/ListingPage/SPECULATE_TRANSACTION_SUCCESS';
export const SPECULATE_TRANSACTION_ERROR = 'app/ListingPage/SPECULATE_TRANSACTION_ERROR';
// ================ Reducer ================ //
const initialState = {
bookingDates: null,
initiateOrderError: null,
listing: null,
bookingDates: null,
speculateTransactionInProgress: false,
speculateTransactionError: null,
speculatedTransaction: null,
initiateOrderError: null,
};
export default function checkoutPageReducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
case SET_INITAL_VALUES:
return { ...state, ...payload };
return { ...initialState, ...payload };
case SPECULATE_TRANSACTION_REQUEST:
return {
...state,
speculateTransactionInProgress: true,
speculateTransactionError: null,
speculatedTransaction: null,
};
case SPECULATE_TRANSACTION_SUCCESS:
return {
...state,
speculateTransactionInProgress: false,
speculatedTransaction: payload.transaction,
};
case SPECULATE_TRANSACTION_ERROR:
console.error(payload); // eslint-disable-line no-console
return {
...state,
speculateTransactionInProgress: false,
speculateTransactionError: payload,
};
case INITIATE_ORDER_REQUEST:
return { ...state, initiateOrderError: null };
case INITIATE_ORDER_SUCCESS:
return state;
case INITIATE_ORDER_ERROR:
console.error(payload); // eslint-disable-line
console.error(payload); // eslint-disable-line no-console
return { ...state, initiateOrderError: payload };
default:
return state;
@ -55,6 +86,21 @@ const initiateOrderError = e => ({
payload: e,
});
export const speculateTransactionRequest = () => ({ type: SPECULATE_TRANSACTION_REQUEST });
export const speculateTransactionSuccess = transaction => ({
type: SPECULATE_TRANSACTION_SUCCESS,
payload: { transaction },
});
export const speculateTransactionError = e => ({
type: SPECULATE_TRANSACTION_ERROR,
error: true,
payload: e,
});
/* ================ Thunks ================ */
export const initiateOrder = params =>
(dispatch, getState, sdk) => {
dispatch(initiateOrderRequest());
@ -74,3 +120,43 @@ export const initiateOrder = params =>
throw e;
});
};
/**
* Initiate the speculative transaction with the given booking details
*
* The API allows us to do speculative transaction initiation and
* transitions. This way we can make create a transaction and get the
* actual pricing information as if the transaction had been started,
* without affecting the actual data.
*
* We store this speculative transaction in the page store and use the
* pricing info for the booking breakdown to get a proper estimate for
* the price with the chosen information.
*/
export const speculateTransaction = (listingId, bookingStart, bookingEnd) =>
(dispatch, getState, sdk) => {
dispatch(speculateTransactionRequest());
const bodyParams = {
transition: propTypes.TX_TRANSITION_PREAUTHORIZE,
params: {
listingId,
bookingStart,
bookingEnd,
cardToken: 'CheckoutPage_speculative_card_token',
},
};
const queryParams = {
include: ['booking', 'provider'],
expand: true,
};
return sdk.transactions
.initiateSpeculative(bodyParams, queryParams)
.then(response => {
const transactionId = response.data.data.id;
const entities = updatedEntities({}, response.data);
const denormalised = denormalisedEntities(entities, 'transaction', [transactionId]);
const tx = denormalised[0];
dispatch(speculateTransactionSuccess(tx));
})
.catch(e => dispatch(speculateTransactionError(e)));
};

View file

@ -3,16 +3,11 @@ import { compose } from 'redux';
import { connect } from 'react-redux';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { withRouter } from 'react-router-dom';
import Decimal from 'decimal.js';
import classNames from 'classnames';
import config from '../../config';
import { types } from '../../util/sdkLoader';
import { pathByRouteName } from '../../util/routes';
import * as propTypes from '../../util/propTypes';
import { ensureListing, ensureUser } from '../../util/data';
import { ensureListing, ensureUser, ensureTransaction, ensureBooking } from '../../util/data';
import { withFlattenedRoutes } from '../../util/contextHelpers';
import { nightsBetween } from '../../util/dates';
import { unitDivisor, convertMoneyToNumber, convertUnitToSubUnit } from '../../util/currency';
import {
AvatarMedium,
BookingBreakdown,
@ -22,75 +17,75 @@ import {
ResponsiveImage,
} from '../../components';
import { StripePaymentForm } from '../../containers';
import { initiateOrder, setInitialValues } from './CheckoutPage.duck';
import { initiateOrder, setInitialValues, speculateTransaction } from './CheckoutPage.duck';
import { storeData, storedData } from './CheckoutPageSessionHelpers';
import { storeData, storedData, clearData } from './CheckoutPageSessionHelpers';
import LogoIcon from './LogoIcon';
import css from './CheckoutPage.css';
const STORAGE_KEY = 'CheckoutPage';
// TODO: This is a temporary function to calculate the booking
// price. This should be removed when the API supports dry-runs and we
// can take the total price from the transaction itself.
const estimatedTotalPrice = (startDate, endDate, unitPrice) => {
const numericPrice = convertMoneyToNumber(unitPrice);
const nightCount = nightsBetween(startDate, endDate);
const numericTotalPrice = new Decimal(numericPrice).times(nightCount).toNumber();
return new types.Money(
convertUnitToSubUnit(numericTotalPrice, unitDivisor(unitPrice.currency)),
unitPrice.currency
);
};
const breakdown = (bookingStart, bookingEnd, unitPrice) => {
if (!bookingStart || !bookingEnd || !unitPrice) {
return null;
}
const totalPrice = estimatedTotalPrice(bookingStart, bookingEnd, unitPrice);
const nightCount = nightsBetween(bookingStart, bookingEnd);
return (
<BookingBreakdown
className={css.bookingBreakdown}
bookingStart={bookingStart}
bookingEnd={bookingEnd}
userRole="customer"
lineItems={[
{
code: 'line-item/night',
quantity: new Decimal(nightCount),
unitPrice: unitPrice,
lineTotal: totalPrice,
},
]}
payinTotal={totalPrice}
/>
);
};
export class CheckoutPageComponent extends Component {
constructor(props) {
super(props);
const { bookingDates, listing } = props;
// Store received data
storeData(bookingDates, listing, STORAGE_KEY);
// storeData(props.bookingDates, props.listing, STORAGE_KEY);
this.state = { submitting: false };
this.state = {
pageData: {},
dataLoaded: false,
submitting: false,
};
this.loadInitialData = this.loadInitialData.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount() {
this.loadInitialData();
}
loadInitialData() {
const { bookingDates, listing } = this.props;
const hasDataInProps = !!(bookingDates && listing);
const pageData = hasDataInProps ? { bookingDates, listing } : storedData(STORAGE_KEY);
if (hasDataInProps) {
storeData(bookingDates, listing, STORAGE_KEY);
}
const hasData = pageData &&
pageData.listing &&
pageData.listing.id &&
pageData.bookingDates &&
pageData.bookingDates.bookingStart &&
pageData.bookingDates.bookingEnd;
if (hasData) {
this.props.fetchSpeculatedTransaction(
pageData.listing.id,
pageData.bookingDates.bookingStart,
pageData.bookingDates.bookingEnd
);
}
this.setState({ pageData: pageData || {}, dataLoaded: true });
}
handleSubmit(cardToken) {
if (this.state.submitting) {
return;
}
this.setState({ submitting: true });
const { bookingDates, flattenedRoutes, history, sendOrderRequest, listing } = this.props;
// Get page data from passed-in props or from storage
const pageData = bookingDates && listing ? { bookingDates, listing } : storedData(STORAGE_KEY);
const { bookingStart, bookingEnd } = pageData.bookingDates || {};
const requestParams = { listingId: pageData.listing.id, cardToken, bookingStart, bookingEnd };
const { flattenedRoutes, history, sendOrderRequest, speculatedTransaction } = this.props;
const requestParams = {
listingId: this.state.pageData.listing.id,
cardToken,
bookingStart: speculatedTransaction.booking.attributes.start,
bookingEnd: speculatedTransaction.booking.attributes.end,
};
sendOrderRequest(requestParams)
.then(orderId => {
@ -98,7 +93,7 @@ export class CheckoutPageComponent extends Component {
const orderDetailsPath = pathByRouteName('OrderDetailsPage', flattenedRoutes, {
id: orderId.uuid,
});
window.sessionStorage.removeItem(STORAGE_KEY);
clearData(STORAGE_KEY);
history.push(orderDetailsPath);
})
.catch(() => {
@ -110,55 +105,65 @@ export class CheckoutPageComponent extends Component {
const {
authInfoError,
logoutError,
bookingDates,
speculateTransactionInProgress,
speculateTransactionError,
speculatedTransaction,
initiateOrderError,
intl,
listing,
params,
currentUser,
} = this.props;
// Get page data from passed-in props or from storage
const pageData = bookingDates && listing ? { bookingDates, listing } : storedData(STORAGE_KEY);
const { bookingStart, bookingEnd } = pageData.bookingDates || {};
const currentListing = ensureListing(pageData.listing);
const isLoading = !this.state.dataLoaded || speculateTransactionInProgress;
const { listing, bookingDates } = this.state.pageData;
const currentTransaction = ensureTransaction(speculatedTransaction);
const currentBooking = ensureBooking(currentTransaction.booking);
const currentListing = ensureListing(listing);
const currentAuthor = ensureUser(currentListing.author);
const authorDisplayName = currentAuthor.attributes.profile.displayName;
const isOwnListing = currentListing.id &&
currentUser &&
const isOwnListing = currentUser &&
currentUser.id &&
currentAuthor &&
currentAuthor.id &&
currentListing.author.id.uuid === currentUser.id.uuid;
currentAuthor.id.uuid === currentUser.id.uuid;
const listingIsOpen = currentListing.id && currentListing.attributes.open;
const hasBookingInfo = bookingStart && bookingEnd;
const hasListingAndAuthor = !!(currentListing.id && currentAuthor.id);
const hasBookingDates = !!(bookingDates &&
bookingDates.bookingStart &&
bookingDates.bookingEnd);
const hasRequiredData = hasListingAndAuthor && hasBookingDates;
const canShowPage = hasRequiredData && listingIsOpen && !isOwnListing;
const shouldRedirect = !isLoading && !canShowPage;
// Redirect back to ListingPage if data is missing.
// Redirection must happen before any data format error is thrown (e.g. wrong currency)
if (!currentListing.id || isOwnListing || !hasBookingInfo) {
if (shouldRedirect) {
// eslint-disable-next-line no-console
console.error(
'Listing, user, or dates invalid for checkout, redirecting back to listing page.',
{ currentListing, isOwnListing, hasBookingInfo, bookingStart, bookingEnd }
);
console.error('Missing or invalid data for checkout, redirecting back to listing page.', {
transaction: currentTransaction,
bookingDates,
listing,
});
return <NamedRedirect name="ListingPage" params={params} />;
}
// Estimate total price. NOTE: this will change when we can do a
// dry-run to the API and get a proper breakdown of the price.
const unitPrice = currentListing.attributes.price;
if (!unitPrice) {
throw new Error('Listing has no price');
}
if (unitPrice.currency !== config.currency) {
throw new Error(
`Listing currency different from marketplace currency: ${unitPrice.currency}`
);
}
const breakdown = currentTransaction.id
? <BookingBreakdown
className={css.bookingBreakdown}
userRole="customer"
transactionState={currentTransaction.attributes.state}
bookingStart={currentBooking.attributes.start}
bookingEnd={currentBooking.attributes.end}
lineItems={currentTransaction.attributes.lineItems}
payinTotal={currentTransaction.attributes.payinTotal}
/>
: null;
// Allow showing page when currentUser is still being downloaded,
// but show payment form only when user info is loaded.
const showPaymentForm = currentUser && !isOwnListing;
const showPaymentForm = !!(currentUser && hasRequiredData);
const listingTitle = currentListing.attributes.title;
const title = intl.formatMessage({ id: 'CheckoutPage.title' }, { listingTitle });
@ -167,28 +172,49 @@ export class CheckoutPageComponent extends Component {
? currentListing.images[0]
: null;
const errorMessage = initiateOrderError
const initiateOrderErrorMessage = initiateOrderError
? <p className={css.orderError}>
<FormattedMessage id="CheckoutPage.initiateOrderError" />
</p>
: null;
const speculateTransactionErrorMessage = speculateTransactionError
? <p className={css.speculateError}>
<FormattedMessage id="CheckoutPage.speculateTransactionError" />
</p>
: null;
const topbar = (
<div className={css.topbar}>
<NamedLink className={css.home} name="LandingPage">
<LogoIcon
className={css.logoMobile}
title={intl.formatMessage({ id: 'CheckoutPage.goToLandingPage' })}
/>
<LogoIcon
className={css.logoDesktop}
alt={intl.formatMessage({ id: 'CheckoutPage.goToLandingPage' })}
isMobile={false}
/>
</NamedLink>
</div>
);
const pageLayoutProps = { authInfoError, logoutError, title };
if (isLoading) {
return (
<PageLayout {...pageLayoutProps}>
{topbar}
<div className={css.loading}>
<FormattedMessage id="CheckoutPage.loadingData" />
</div>
</PageLayout>
);
}
return (
<PageLayout authInfoError={authInfoError} logoutError={logoutError} title={title}>
<div className={css.topbar}>
<NamedLink className={css.home} name="LandingPage">
<LogoIcon
className={css.logoMobile}
title={intl.formatMessage({ id: 'CheckoutPage.goToLandingPage' })}
/>
<LogoIcon
className={css.logoDesktop}
alt={intl.formatMessage({ id: 'CheckoutPage.goToLandingPage' })}
isMobile={false}
/>
</NamedLink>
</div>
<PageLayout {...pageLayoutProps}>
{topbar}
<div className={css.contentContainer}>
<div className={css.aspectWrapper}>
<ResponsiveImage
@ -212,7 +238,7 @@ export class CheckoutPageComponent extends Component {
<span className={css.authorName}>
<FormattedMessage
id="ListingPage.hostedBy"
values={{ name: authorDisplayName }}
values={{ name: currentAuthor.attributes.profile.displayName }}
/>
</span>
</div>
@ -222,14 +248,15 @@ export class CheckoutPageComponent extends Component {
<h3 className={css.priceBreakdownTitle}>
<FormattedMessage id="CheckoutPage.priceBreakdownTitle" />
</h3>
{breakdown(bookingStart, bookingEnd, unitPrice)}
{speculateTransactionErrorMessage}
{breakdown}
</div>
<section className={css.paymentContainer}>
<h3 className={css.paymentTitle}>
<FormattedMessage id="CheckoutPage.paymentTitle" />
</h3>
{errorMessage}
{initiateOrderErrorMessage}
{showPaymentForm
? <StripePaymentForm
className={css.paymentForm}
@ -261,13 +288,17 @@ export class CheckoutPageComponent extends Component {
<div className={css.detailsHeadings}>
<h2 className={css.detailsTitle}>{listingTitle}</h2>
<p className={css.detailsSubtitle}>
<FormattedMessage id="CheckoutPage.hostedBy" values={{ name: authorDisplayName }} />
<FormattedMessage
id="CheckoutPage.hostedBy"
values={{ name: currentAuthor.attributes.profile.displayName }}
/>
</p>
</div>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="CheckoutPage.priceBreakdownTitle" />
</h3>
{breakdown(bookingStart, bookingEnd, unitPrice)}
{speculateTransactionErrorMessage}
{breakdown}
</div>
</div>
@ -278,23 +309,29 @@ export class CheckoutPageComponent extends Component {
CheckoutPageComponent.defaultProps = {
authInfoError: null,
bookingDates: null,
initiateOrderError: null,
listing: null,
bookingDates: null,
speculateTransactionError: null,
speculatedTransaction: null,
logoutError: null,
currentUser: null,
};
const { arrayOf, func, instanceOf, shape, string } = PropTypes;
const { arrayOf, func, instanceOf, shape, string, bool } = PropTypes;
CheckoutPageComponent.propTypes = {
authInfoError: instanceOf(Error),
listing: propTypes.listing,
bookingDates: shape({
bookingStart: instanceOf(Date).isRequired,
bookingEnd: instanceOf(Date).isRequired,
}),
fetchSpeculatedTransaction: func.isRequired,
speculateTransactionInProgress: bool.isRequired,
speculateTransactionError: instanceOf(Error),
speculatedTransaction: propTypes.transaction,
initiateOrderError: instanceOf(Error),
listing: propTypes.listing,
logoutError: instanceOf(Error),
currentUser: propTypes.currentUser,
params: shape({
@ -316,14 +353,33 @@ CheckoutPageComponent.propTypes = {
};
const mapStateToProps = state => {
const { initiateOrderError, listing, bookingDates } = state.CheckoutPage;
const {
listing,
bookingDates,
speculateTransactionInProgress,
speculateTransactionError,
speculatedTransaction,
initiateOrderError,
} = state.CheckoutPage;
const { currentUser } = state.user;
const { authInfoError, logoutError } = state.Auth;
return { authInfoError, initiateOrderError, listing, logoutError, bookingDates, currentUser };
return {
currentUser,
authInfoError,
logoutError,
bookingDates,
speculateTransactionInProgress,
speculateTransactionError,
speculatedTransaction,
listing,
initiateOrderError,
};
};
const mapDispatchToProps = dispatch => ({
sendOrderRequest: params => dispatch(initiateOrder(params)),
fetchSpeculatedTransaction: (listingId, bookingStart, bookingEnd) =>
dispatch(speculateTransaction(listingId, bookingStart, bookingEnd)),
});
const CheckoutPage = compose(

View file

@ -21,6 +21,8 @@ describe('CheckoutPage', () => {
currentUser: createCurrentUser('current-user'),
params: { id: 'listing1', slug: 'listing1' },
sendOrderRequest: noop,
fetchSpeculatedTransaction: noop,
speculateTransactionInProgress: false,
};
const tree = renderShallow(<CheckoutPageComponent {...props} />);
expect(tree).toMatchSnapshot();
@ -43,12 +45,16 @@ describe('CheckoutPage', () => {
});
describe('Reducer', () => {
const initialValues = {
initiateOrderError: null,
listing: null,
bookingDates: null,
speculateTransactionError: null,
speculateTransactionInProgress: false,
speculatedTransaction: null,
};
it('should return the initial state', () => {
const initialValues = {
initiateOrderError: null,
listing: null,
bookingDates: null,
};
expect(checkoutPageReducer(undefined, {})).toEqual(initialValues);
});
@ -60,7 +66,8 @@ describe('CheckoutPage', () => {
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
};
const payload = { listing, bookingDates };
expect(checkoutPageReducer({}, { type: SET_INITAL_VALUES, payload })).toEqual(payload);
const expected = { ...initialValues, ...payload };
expect(checkoutPageReducer({}, { type: SET_INITAL_VALUES, payload })).toEqual(expected);
});
});
});

View file

@ -18,193 +18,9 @@ exports[`CheckoutPage matches snapshot 1`] = `
</withFlattenedRoutes(withRouter(NamedLink))>
</div>
<div>
<div>
<ResponsiveImage
alt="listing1 title"
className={null}
image={null}
nameSet={
Array [
Object {
"name": "landscape-crop",
"size": "400w",
},
Object {
"name": "landscape-crop2x",
"size": "800w",
},
]
}
noImageMessage={null}
rootClassName={null}
sizes="100vw" />
</div>
<div
className="">
<AvatarMedium
user={
Object {
"attributes": Object {
"profile": Object {
"abbreviatedName": "author abbreviated name",
"displayName": "author display name",
},
},
"id": UUID {
"uuid": "author",
},
"type": "user",
}
} />
</div>
<div>
<div>
<h1>
CheckoutPage.title
</h1>
<div>
<span>
<FormattedMessage
id="ListingPage.hostedBy"
values={
Object {
"name": "author display name",
}
} />
</span>
</div>
</div>
<div>
<h3>
<FormattedMessage
id="CheckoutPage.priceBreakdownTitle"
values={Object {}} />
</h3>
<BookingBreakdown
bookingEnd={2017-04-16T00:00:00.000Z}
bookingStart={2017-04-14T00:00:00.000Z}
lineItems={
Array [
Object {
"code": "line-item/night",
"lineTotal": Money {
"amount": 11000,
"currency": "USD",
},
"quantity": "2",
"unitPrice": Money {
"amount": 5500,
"currency": "USD",
},
},
]
}
payinTotal={
Money {
"amount": 11000,
"currency": "USD",
}
}
userRole="customer" />
</div>
<section>
<h3>
<FormattedMessage
id="CheckoutPage.paymentTitle"
values={Object {}} />
</h3>
<InjectIntl(StripePaymentForm)
disableSubmit={false}
formId="CheckoutPagePaymentForm"
onSubmit={[Function]}
paymentInfo="CheckoutPage.paymentInfo" />
</section>
</div>
<div>
<div>
<ResponsiveImage
alt="listing1 title"
className={null}
image={null}
nameSet={
Array [
Object {
"name": "landscape-crop",
"size": "400w",
},
Object {
"name": "landscape-crop2x",
"size": "800w",
},
]
}
noImageMessage={null}
rootClassName={null}
sizes="100%" />
</div>
<div>
<AvatarMedium
user={
Object {
"attributes": Object {
"profile": Object {
"abbreviatedName": "author abbreviated name",
"displayName": "author display name",
},
},
"id": UUID {
"uuid": "author",
},
"type": "user",
}
} />
</div>
<div>
<h2>
listing1 title
</h2>
<p>
<FormattedMessage
id="CheckoutPage.hostedBy"
values={
Object {
"name": "author display name",
}
} />
</p>
</div>
<h3>
<FormattedMessage
id="CheckoutPage.priceBreakdownTitle"
values={Object {}} />
</h3>
<BookingBreakdown
bookingEnd={2017-04-16T00:00:00.000Z}
bookingStart={2017-04-14T00:00:00.000Z}
lineItems={
Array [
Object {
"code": "line-item/night",
"lineTotal": Money {
"amount": 11000,
"currency": "USD",
},
"quantity": "2",
"unitPrice": Money {
"amount": 5500,
"currency": "USD",
},
},
]
}
payinTotal={
Money {
"amount": 11000,
"currency": "USD",
}
}
userRole="customer" />
</div>
<FormattedMessage
id="CheckoutPage.loadingData"
values={Object {}} />
</div>
</withRouter(PageLayout)>
`;

View file

@ -30,10 +30,12 @@
"CheckoutPage.goToLandingPage": "Go to homepage",
"CheckoutPage.hostedBy": "Hosted by {name}",
"CheckoutPage.initiateOrderError": "Payment request failed. Please try again.",
"CheckoutPage.loadingData": "Loading checkout data...",
"CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.",
"CheckoutPage.paymentTitle": "Payment",
"CheckoutPage.priceBreakdownTitle": "Booking breakdown",
"CheckoutPage.title": "Book {listingTitle}",
"CheckoutPage.speculateTransactionError": "Failed to fetch breakdown information. Please try again.",
"DateInput.clearDate": "Clear Date",
"DateInput.closeDatePicker": "Close",
"DateInput.defaultPlaceholder": "Date input",