From 8f55474ad245acc362030dd2a48ed3626221f8b2 Mon Sep 17 00:00:00 2001 From: "e.kireev" Date: Tue, 5 Mar 2024 22:33:09 +0300 Subject: [PATCH 1/2] feat(PAYMENTS-17869): async status update for qr code and mobile payment methods --- src/core/actions/next-action.interface.ts | 4 +++- .../show-mobile-payment-screen.action.type.ts | 12 ++++++++++++ src/core/actions/show-qr-code.action.type.ts | 9 ++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/core/actions/show-mobile-payment-screen.action.type.ts diff --git a/src/core/actions/next-action.interface.ts b/src/core/actions/next-action.interface.ts index e11ad76..f3f07ca 100644 --- a/src/core/actions/next-action.interface.ts +++ b/src/core/actions/next-action.interface.ts @@ -6,6 +6,7 @@ import { StatusUpdatedAction } from './status-updated.action.type'; import { ThreeDsAction } from './three-ds/three-ds.action.type'; import { SpecialButtonAction } from './special-button.action.type'; import { ShowQrCodeAction } from './show-qr-code.action.type'; +import { ShowMobilePaymentScreenAction } from './show-mobile-payment-screen.action.type'; export type NextAction = | CheckStatusAction @@ -15,4 +16,5 @@ export type NextAction = | RedirectAction | ThreeDsAction | SpecialButtonAction - | ShowQrCodeAction; + | ShowQrCodeAction + | ShowMobilePaymentScreenAction; diff --git a/src/core/actions/show-mobile-payment-screen.action.type.ts b/src/core/actions/show-mobile-payment-screen.action.type.ts new file mode 100644 index 0000000..ae3f05d --- /dev/null +++ b/src/core/actions/show-mobile-payment-screen.action.type.ts @@ -0,0 +1,12 @@ +import { Action } from './action.interface'; + +export type ShowMobilePaymentScreenActionType = 'show_mobile_payment_screen'; + +export interface ShowMobilePaymentScreenActionData { + submitButtonText: string; +} + +export type ShowMobilePaymentScreenAction = Action< + ShowMobilePaymentScreenActionType, + ShowMobilePaymentScreenActionData +>; diff --git a/src/core/actions/show-qr-code.action.type.ts b/src/core/actions/show-qr-code.action.type.ts index 45d0d38..1697d79 100644 --- a/src/core/actions/show-qr-code.action.type.ts +++ b/src/core/actions/show-qr-code.action.type.ts @@ -2,4 +2,11 @@ import { Action } from './action.interface'; export type ShowQrCodeActionType = 'show_qr_code'; -export type ShowQrCodeAction = Action; +export interface ShowQrCodeActionData { + submitButtonText: string; +} + +export type ShowQrCodeAction = Action< + ShowQrCodeActionType, + ShowQrCodeActionData +>; From 1d5a6312e706ac7815622e1dac96b23b2884f1f2 Mon Sep 17 00:00:00 2001 From: "e.kireev" Date: Wed, 6 Mar 2024 15:26:34 +0300 Subject: [PATCH 2/2] feat: example for Payment Form Component --- examples/credit-card/init-payment-flow.js | 5 +- examples/credit-card/return.js | 4 +- examples/google-pay/index.html | 3 +- examples/payment-form/index.html | 134 +++++++++++++++++ examples/payment-form/style.css | 135 ++++++++++++++++++ examples/paypal/return.html | 2 +- .../actions/is-show-fields-action.function.ts | 8 ++ .../payment-form/field-settings.interface.ts | 4 + .../get-invalid-fields-names.function.spec.ts | 22 --- .../get-invalid-fields-names.function.ts | 32 ----- .../get-invalid-fields.function.spec.ts | 36 +++++ .../get-invalid-fields.function.ts | 42 ++++++ .../get-missed-fields-names.function.spec.ts | 12 -- .../get-missed-fields-names.function.ts | 20 --- .../get-missed-fields.function.spec.ts | 19 +++ .../get-missed-fields.function.ts | 22 +++ ...et-web-component-by-field-name.function.ts | 28 +++- .../payment-form-fields.service.spec.ts | 14 +- .../payment-form-fields.service.ts | 26 ++-- .../payment-form.component.spec.ts | 38 +++-- .../payment-form/payment-form.component.ts | 105 ++++++++------ .../web-components-fields-names.map.ts | 1 + .../submit-button/submit-button.component.ts | 15 ++ src/web-components.ts | 2 + 24 files changed, 548 insertions(+), 181 deletions(-) create mode 100644 examples/payment-form/index.html create mode 100644 examples/payment-form/style.css create mode 100644 src/core/actions/is-show-fields-action.function.ts create mode 100644 src/features/headless-checkout/web-components/payment-form/field-settings.interface.ts delete mode 100644 src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.spec.ts delete mode 100644 src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.ts create mode 100644 src/features/headless-checkout/web-components/payment-form/get-invalid-fields.function.spec.ts create mode 100644 src/features/headless-checkout/web-components/payment-form/get-invalid-fields.function.ts delete mode 100644 src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.spec.ts delete mode 100644 src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.ts create mode 100644 src/features/headless-checkout/web-components/payment-form/get-missed-fields.function.spec.ts create mode 100644 src/features/headless-checkout/web-components/payment-form/get-missed-fields.function.ts diff --git a/examples/credit-card/init-payment-flow.js b/examples/credit-card/init-payment-flow.js index 3593bb4..a16b70d 100644 --- a/examples/credit-card/init-payment-flow.js +++ b/examples/credit-card/init-payment-flow.js @@ -160,7 +160,7 @@ function buildPaymentFlow() { */ await headlessCheckout.init({ isWebView: false, - sandbox: false, + sandbox: true, }); /** @@ -206,7 +206,8 @@ function buildPaymentFlow() { /* * This return URL means you start the current example on localhost with the 3000 port. * */ - returnUrl: 'http://localhost:3000/return.html', + returnUrl: + 'http://localhost:3000/pay-station-sdk/examples/credit-card/return.html', }); /** diff --git a/examples/credit-card/return.js b/examples/credit-card/return.js index 68a73f3..f13cc17 100644 --- a/examples/credit-card/return.js +++ b/examples/credit-card/return.js @@ -16,7 +16,7 @@ function buildPaymentFlow() { * For more information about creating tokens, * refer to our documentation https://developers.xsolla.com/api/pay-station/operation/create-token/ */ - const accessToken = ''; + const accessToken = new URL(window.location.href).searchParams.get('token'); if (!accessToken) { alert('No token provided. Please, check the documentation'); @@ -32,7 +32,7 @@ function buildPaymentFlow() { async function initPayStationSdk() { await headlessCheckout.init({ isWebView: false, - sandbox: false, + sandbox: true, }); await headlessCheckout.setToken(accessToken); diff --git a/examples/google-pay/index.html b/examples/google-pay/index.html index d3540df..eed1170 100644 --- a/examples/google-pay/index.html +++ b/examples/google-pay/index.html @@ -88,7 +88,8 @@

GooglePay payment integration (only https)

const form = await headlessCheckout.form.init({ paymentMethodId: googlePayPaymentMethodId, - returnUrl: 'https://headless-checkout-app.web.app/return' + returnUrl: + 'http://localhost:3000/pay-station-sdk/examples/credit-card/return.html' }); /** diff --git a/examples/payment-form/index.html b/examples/payment-form/index.html new file mode 100644 index 0000000..7d46847 --- /dev/null +++ b/examples/payment-form/index.html @@ -0,0 +1,134 @@ + + + + + + + Document + + + + + + +

Pay Station SDK

+ +

Payment Form component integration

+ +
+
+ + + + + + + + + + diff --git a/examples/payment-form/style.css b/examples/payment-form/style.css new file mode 100644 index 0000000..c0d8193 --- /dev/null +++ b/examples/payment-form/style.css @@ -0,0 +1,135 @@ +/* *** *** COMMON: START *** *** */ + +.application { + margin: 0 auto; + width: 100%; + max-width: 700px; +} + +.columns-wrapper { + display: flex; + padding-bottom: 20px; +} + +.left-col, +.right-col { + width: 50%; +} + +/* *** *** COMMON: END *** *** */ + +/* *** *** CONTROLS: START *** *** */ +psdk-text, +psdk-card-number { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 400px; + height: 60px; + margin-bottom: 30px; +} + +psdk-card-number .wrapper { + align-items: center; +} + +psdk-text iframe, +psdk-card-number iframe { + border: none; + width: inherit; + height: 30px; +} + +.field-error { + color: #f30; +} + +/* Select style: the drop-down list will be hidden. */ +psdk-select button.select { + width: 100%; + max-width: 200px; +} + +.dropdown-wrapper { + display: none; +} + +/* Select style: the drop-down list will be opened. */ +.dropdown-wrapper.dropdown-opened { + display: block; +} + +.dropdown { + border: 1px solid #c2c2c2; + border-radius: 8px; +} + +.dropdown .select-options .option { + padding: 4px 8px; + cursor: pointer; + display: block; +} + +.dropdown .select-options .option:hover { + background: #e8e8e8; +} + +psdk-submit-button { + width: 100%; + max-width: 300px; + padding: 3px 6px; +} + +/* *** *** CONTROLS: END *** *** */ + +/* *** *** FINANCE DETAILS: START *** *** */ + +psdk-finance-details { + display: block; + background: #f5f5f5; + margin-right: 10px; + padding: 10px; +} + +.subtotal-row, +.total-row { + display: flex; + width: 100%; + justify-content: space-between; + margin: 10px 0; +} + +/* *** *** FINANCE DETAILS: END *** *** */ + +/* *** *** STATUS: START *** *** */ +psdk-status { + display: flex; +} + +psdk-status .image { + display: block; + width: 100%; + height: auto; +} + +psdk-status .title-text { + text-align: center; +} +/* *** *** STATUS: END *** *** */ + +/* *** *** FOOTER LINKS: START *** *** */ +.company { + padding-top: 5px; + display: flex; +} + +.company .logo { + margin-right: 3px; +} + +.legal-links { + padding-top: 5px; + display: flex; + justify-content: space-between; +} +/* *** *** FOOTER LINKS: END *** *** */ diff --git a/examples/paypal/return.html b/examples/paypal/return.html index 7ee1b96..f9f82b1 100644 --- a/examples/paypal/return.html +++ b/examples/paypal/return.html @@ -40,7 +40,7 @@

PayPal payment return page

* To learn more about creating tokens, * please read https://developers.xsolla.com/api/pay-station/operation/create-token/ */ - const accessToken = ''; + const accessToken = new URL(window.location.href).searchParams.get('token'); if (!accessToken) { alert('No token provided. Please, check the documentation'); diff --git a/src/core/actions/is-show-fields-action.function.ts b/src/core/actions/is-show-fields-action.function.ts new file mode 100644 index 0000000..23210fa --- /dev/null +++ b/src/core/actions/is-show-fields-action.function.ts @@ -0,0 +1,8 @@ +import { Action } from './action.interface'; +import { ShowFieldsAction } from './show-fields.action.type'; + +export const isShowFieldsAction = ( + value: Action, +): value is ShowFieldsAction => { + return value.type === 'show_fields'; +}; diff --git a/src/features/headless-checkout/web-components/payment-form/field-settings.interface.ts b/src/features/headless-checkout/web-components/payment-form/field-settings.interface.ts new file mode 100644 index 0000000..c90a1f2 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/field-settings.interface.ts @@ -0,0 +1,4 @@ +export interface FieldSettings { + name: string; + type: string; +} diff --git a/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.spec.ts b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.spec.ts deleted file mode 100644 index e08d687..0000000 --- a/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getInvalidFieldsNames } from './get-invalid-fields-names.function'; - -const expectedFieldsNames = ['zip', 'email']; - -describe('getInvalidFieldsNames', () => { - it('Should return invalid names', () => { - const exists = ['zip', 'card']; - expect(getInvalidFieldsNames(expectedFieldsNames, exists)).toEqual([ - 'card', - ]); - }); - - it('Should return invalid names if duplicates', () => { - const exists = ['zip', 'zip']; - expect(getInvalidFieldsNames(expectedFieldsNames, exists)).toEqual(['zip']); - }); - - it('Should not filter empty name fields', () => { - const exists = ['zip', '']; - expect(getInvalidFieldsNames(expectedFieldsNames, exists)).toEqual([]); - }); -}); diff --git a/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.ts b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.ts deleted file mode 100644 index 520eb27..0000000 --- a/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.ts +++ /dev/null @@ -1,32 +0,0 @@ -export function getInvalidFieldsNames( - expectedFieldsNames: string[], - existsControlsNames: Array -): string[] { - const fieldsExistsCountMap: { [k: string]: number } = {}; - for (const name of existsControlsNames) { - if (name) { - const count = fieldsExistsCountMap[name]; - if (!count) { - fieldsExistsCountMap[name] = 1; - } else { - fieldsExistsCountMap[name] = count + 1; - } - } - } - - const invalidFieldNames = []; - - // eslint-disable-next-line prefer-const - for (let [name, count] of Object.entries(fieldsExistsCountMap)) { - if (!expectedFieldsNames.some((value) => value === name)) { - invalidFieldNames.push(name); - } else if (count > 1) { - while (count > 1) { - invalidFieldNames.push(name); - count--; - } - } - } - - return invalidFieldNames; -} diff --git a/src/features/headless-checkout/web-components/payment-form/get-invalid-fields.function.spec.ts b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields.function.spec.ts new file mode 100644 index 0000000..0782315 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields.function.spec.ts @@ -0,0 +1,36 @@ +import { getInvalidFields } from './get-invalid-fields.function'; + +const expectedFields = [ + { name: 'zip', type: 'text' }, + { name: 'email', type: 'text' }, +]; + +describe('getInvalidFields', () => { + it('Should return invalid fields', () => { + const exists = [ + { name: 'zip', type: 'text' }, + { name: 'card', type: 'text' }, + ]; + expect(getInvalidFields(expectedFields, exists)).toEqual([ + { name: 'card', type: 'text' }, + ]); + }); + + it('Should return invalid fields if duplicates', () => { + const exists = [ + { name: 'zip', type: 'text' }, + { name: 'zip', type: 'text' }, + ]; + expect(getInvalidFields(expectedFields, exists)).toEqual([ + { name: 'zip', type: 'text' }, + ]); + }); + + it('Should not filter empty name fields', () => { + const exists = [ + { name: 'zip', type: 'text' }, + { name: '', type: 'text' }, + ]; + expect(getInvalidFields(expectedFields, exists)).toEqual([]); + }); +}); diff --git a/src/features/headless-checkout/web-components/payment-form/get-invalid-fields.function.ts b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields.function.ts new file mode 100644 index 0000000..f5ce9a9 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields.function.ts @@ -0,0 +1,42 @@ +import { FieldSettings } from './field-settings.interface'; + +export function getInvalidFields( + expectedFieldsNames: FieldSettings[], + existsControlsNames: FieldSettings[], +): FieldSettings[] { + const fieldsExistsCountMap: { [k: string]: number } = {}; + for (const { name } of existsControlsNames) { + if (name) { + const count = fieldsExistsCountMap[name]; + if (!count) { + fieldsExistsCountMap[name] = 1; + } else { + fieldsExistsCountMap[name] = count + 1; + } + } + } + + const invalidFieldNames: FieldSettings[] = []; + + // eslint-disable-next-line prefer-const + for (let [name, count] of Object.entries(fieldsExistsCountMap)) { + const invalidField = existsControlsNames.find( + (field) => field.name === name, + ); + + if (!invalidField) { + continue; + } + + if (!expectedFieldsNames.some((field) => field.name === name)) { + invalidFieldNames.push(invalidField); + } else if (count > 1) { + while (count > 1) { + invalidFieldNames.push(invalidField); + count--; + } + } + } + + return invalidFieldNames; +} diff --git a/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.spec.ts b/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.spec.ts deleted file mode 100644 index b64206d..0000000 --- a/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getMissedFieldsNames } from './get-missed-fields-names.function'; - -const expectedFieldsNames = ['zip', 'email']; - -describe('getMissedFieldsNames', () => { - it('Should return missed names', () => { - const exists = ['zip', 'card', '']; - expect(getMissedFieldsNames(expectedFieldsNames, exists)).toEqual([ - 'email', - ]); - }); -}); diff --git a/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.ts b/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.ts deleted file mode 100644 index 0a201e4..0000000 --- a/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function getMissedFieldsNames( - expectedFieldsNames: string[], - existsControlsNames: Array -): string[] { - const fieldsExistsMap: { [k: string]: boolean } = {}; - - for (const name of existsControlsNames) { - if (name) { - fieldsExistsMap[name] = true; - } - } - - const missedFieldsNames = []; - for (const name of expectedFieldsNames) { - if (!fieldsExistsMap[name]) { - missedFieldsNames.push(name); - } - } - return missedFieldsNames; -} diff --git a/src/features/headless-checkout/web-components/payment-form/get-missed-fields.function.spec.ts b/src/features/headless-checkout/web-components/payment-form/get-missed-fields.function.spec.ts new file mode 100644 index 0000000..7e017e0 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/get-missed-fields.function.spec.ts @@ -0,0 +1,19 @@ +import { getMissedFields } from './get-missed-fields.function'; +import { FieldSettings } from './field-settings.interface'; + +const expectedFields = [ + { name: 'zip', type: 'text' }, + { name: 'email', type: 'text' }, +]; + +describe('getMissedFields', () => { + it('Should return missed fields', () => { + const exists: FieldSettings[] = [ + { name: 'zip', type: 'text' }, + { name: 'card', type: 'text' }, + ]; + expect(getMissedFields(expectedFields, exists)).toEqual([ + { name: 'email', type: 'text' }, + ]); + }); +}); diff --git a/src/features/headless-checkout/web-components/payment-form/get-missed-fields.function.ts b/src/features/headless-checkout/web-components/payment-form/get-missed-fields.function.ts new file mode 100644 index 0000000..69e8a3a --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/get-missed-fields.function.ts @@ -0,0 +1,22 @@ +import { FieldSettings } from './field-settings.interface'; + +export function getMissedFields( + expectedFieldsNamesAndTypes: FieldSettings[], + existsControlsNamesAndTypes: FieldSettings[], +): FieldSettings[] { + const fieldsExistsMap: { [k: string]: boolean } = {}; + + for (const field of existsControlsNamesAndTypes) { + if (field) { + fieldsExistsMap[field.name] = true; + } + } + + const missedFieldsNames = []; + for (const field of expectedFieldsNamesAndTypes) { + if (!fieldsExistsMap[field.name]) { + missedFieldsNames.push(field); + } + } + return missedFieldsNames; +} diff --git a/src/features/headless-checkout/web-components/payment-form/get-web-component-by-field-name.function.ts b/src/features/headless-checkout/web-components/payment-form/get-web-component-by-field-name.function.ts index d2e55ff..e638a38 100644 --- a/src/features/headless-checkout/web-components/payment-form/get-web-component-by-field-name.function.ts +++ b/src/features/headless-checkout/web-components/payment-form/get-web-component-by-field-name.function.ts @@ -1,11 +1,25 @@ import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; -import { webComponentsFieldsNamesMap } from './web-components-fields-names.map'; +import { FieldSettings } from './field-settings.interface'; -export const getWebComponentByFieldName = ( - name: string -): WebComponentTagName => { - const webComponent: undefined | WebComponentTagName = - webComponentsFieldsNamesMap[name]; +export const getWebComponent = (field: FieldSettings): WebComponentTagName => { + if (field.type === 'text' && field.name === 'card_number') { + return WebComponentTagName.CardNumberComponent; + } - return webComponent ?? WebComponentTagName.TextComponent; + if (field.type === 'text' && field.name === 'phone') { + return WebComponentTagName.PhoneComponent; + } + if (field.type === 'text') { + return WebComponentTagName.TextComponent; + } + + if (field.type === 'select') { + return WebComponentTagName.SelectComponent; + } + + if (field.type === 'check') { + return WebComponentTagName.CheckboxComponent; + } + + return WebComponentTagName.TextComponent; }; diff --git a/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.spec.ts b/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.spec.ts index 1b80840..4861466 100644 --- a/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.spec.ts +++ b/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.spec.ts @@ -8,7 +8,7 @@ const getTextInputElements = (names: string[]): NodeListOf => { const mockContainer = document.createElement('div'); for (const name of names) { const mockElement = document.createElement( - WebComponentTagName.TextComponent + WebComponentTagName.TextComponent, ); mockElement.setAttribute('name', name); mockContainer.appendChild(mockElement); @@ -34,14 +34,14 @@ describe('PaymentFormFieldsManager', () => { it('Should create missed fields and append them into body', () => { const textInputElements = getTextInputElements([]); spyOn(windowService.document, 'querySelectorAll').and.returnValue( - textInputElements + textInputElements, ); - const missedFields = ['card']; + const missedFields = [{ name: 'card', type: 'text' }]; const mockBody = { appendChild: noopStub, } as unknown as HTMLElement; spyOn(windowService.document, 'querySelector').and.returnValue( - mockBody as unknown as Element + mockBody as unknown as Element, ); const spy = spyOn(mockBody, 'appendChild'); paymentFormFieldsManager.createMissedFields(missedFields, mockBody); @@ -49,12 +49,12 @@ describe('PaymentFormFieldsManager', () => { }); it('Should remove extra field', () => { - const extraFields = ['card', null]; + const extraFields = [{ name: 'card', type: 'text' }]; const mockElement = { remove: noopStub, }; spyOn(windowService.document, 'querySelector').and.returnValue( - mockElement as unknown as Element + mockElement as unknown as Element, ); const spy = spyOn(mockElement, 'remove'); paymentFormFieldsManager.removeExtraFields(extraFields); @@ -67,7 +67,7 @@ describe('PaymentFormFieldsManager', () => { element.removeAttribute('name'); }); spyOn(windowService.document, 'querySelectorAll').and.returnValue( - textInputElements + textInputElements, ); const spy = spyOn(textInputElements[0], 'remove'); paymentFormFieldsManager.removeEmptyNameFields(); diff --git a/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.ts b/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.ts index b9dff13..6382b33 100644 --- a/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.ts +++ b/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.ts @@ -1,41 +1,41 @@ import { injectable } from 'tsyringe'; import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; -import { getWebComponentByFieldName } from './get-web-component-by-field-name.function'; +import { getWebComponent } from './get-web-component-by-field-name.function'; +import { FieldSettings } from './field-settings.interface'; @injectable() export class PaymentFormFieldsService { public constructor(private readonly window: Window) {} public createMissedFields( - missedFieldsNames: string[], - container: HTMLElement + missedFields: FieldSettings[], + container: HTMLElement, ): void { const documentFragment = this.window.document.createDocumentFragment(); - for (const name of missedFieldsNames) { + for (const field of missedFields) { const formElement = this.window.document.createElement( - getWebComponentByFieldName(name) + getWebComponent(field), ); - formElement.setAttribute('name', name); + formElement.setAttribute('name', field.name); documentFragment.append(formElement); } container.appendChild(documentFragment); } - public removeExtraFields(extraFieldsNames: Array): void { - for (const name of extraFieldsNames) { - if (!name) { - continue; - } - const formElement = this.window.document.querySelector(`[name=${name}]`); + public removeExtraFields(extraFields: FieldSettings[]): void { + for (const field of extraFields) { + const formElement = this.window.document.querySelector( + `[name=${field.name}]`, + ); formElement?.remove(); } } public removeEmptyNameFields(): void { const formInputs = this.window.document.querySelectorAll( - WebComponentTagName.TextComponent + WebComponentTagName.TextComponent, ); Array.from(formInputs).forEach((formInput) => { if (!formInput.getAttribute('name')) { diff --git a/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts b/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts index 7813a74..441d4de 100644 --- a/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts +++ b/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts @@ -7,10 +7,11 @@ import { PaymentFormFieldsService } from './payment-form-fields.service'; import { PaymentFormComponent } from './payment-form.component'; import { Field } from '../../../../core/form/field.interface'; import { PostMessagesClient } from '../../../../core/post-messages-client/post-messages-client'; +import { FieldSettings } from './field-settings.interface'; function createComponent(): void { const element = document.createElement( - WebComponentTagName.PaymentFormComponent + WebComponentTagName.PaymentFormComponent, ); element.setAttribute('id', 'test'); (document.getElementById('container')! as HTMLElement).appendChild(element); @@ -19,9 +20,11 @@ function createComponent(): void { const mockFormFields: Field[] = [ { name: 'zip', + type: 'text', }, { name: 'card', + type: 'text', }, ] as unknown as Field[]; @@ -29,7 +32,7 @@ const getTextInputElements = (names: string[]): NodeListOf => { const mockContainer = document.createElement('div'); for (const name of names) { const mockElement = document.createElement( - WebComponentTagName.TextComponent + WebComponentTagName.TextComponent, ); mockElement.setAttribute('name', name); mockContainer.appendChild(mockElement); @@ -47,7 +50,7 @@ describe('PaymentFormComponent', () => { window.customElements.define( WebComponentTagName.PaymentFormComponent, - PaymentFormComponent + PaymentFormComponent, ); beforeEach(() => { @@ -60,6 +63,7 @@ describe('PaymentFormComponent', () => { }, form: { onFieldsStatusChange: noopStub, + onNextAction: noopStub, }, } as unknown as HeadlessCheckout; @@ -110,7 +114,7 @@ describe('PaymentFormComponent', () => { it('Should create component', () => { createComponent(); expect( - document.querySelector(WebComponentTagName.PaymentFormComponent) + document.querySelector(WebComponentTagName.PaymentFormComponent), ).toBeDefined(); }); @@ -140,7 +144,7 @@ describe('PaymentFormComponent', () => { const textInputElements = getTextInputElements(['zip']); spyOn(windowService.document, 'querySelectorAll').and.returnValue( - textInputElements + textInputElements, ); const spy = spyOn(paymentFormFieldsManager, 'createMissedFields'); @@ -165,23 +169,27 @@ describe('PaymentFormComponent', () => { const textInputElements = getTextInputElements(['zip', 'zip']); spyOn(windowService.document, 'querySelectorAll').and.returnValue( - textInputElements + textInputElements, ); const spy = spyOn(paymentFormFieldsManager, 'removeExtraFields'); createComponent(); + const zip: FieldSettings = { + name: 'zip', + type: 'text', + }; // rewrite querySelectorAll mock expect(spy).toHaveBeenCalledWith([ - 'zip', - 'zip', - 'zip', - 'zip', - 'zip', - 'zip', - 'zip', - 'zip', - 'zip', + zip, + zip, + zip, + zip, + zip, + zip, + zip, + zip, + zip, ]); }); }); diff --git a/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts b/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts index 39af03e..2bfc3c5 100644 --- a/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts +++ b/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts @@ -4,11 +4,13 @@ import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; import { Field } from '../../../../core/form/field.interface'; import { HeadlessCheckout } from '../../headless-checkout'; import { EventName } from '../../../../core/event-name.enum'; -import { getMissedFieldsNames } from './get-missed-fields-names.function'; -import { getInvalidFieldsNames } from './get-invalid-fields-names.function'; +import { getMissedFields } from './get-missed-fields.function'; +import { getInvalidFields } from './get-invalid-fields.function'; import { PaymentFormFieldsService } from './payment-form-fields.service'; import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; import { formControlsTags } from './form-controls-tags.list'; +import { isShowFieldsAction } from '../../../../core/actions/is-show-fields-action.function'; +import { FieldSettings } from './field-settings.interface'; export class PaymentFormComponent extends WebComponentAbstract { private readonly headlessCheckout: HeadlessCheckout; @@ -38,15 +40,12 @@ export class PaymentFormComponent extends WebComponentAbstract { super.render(); if (formExpectedFields) { - const expectedFieldsNames = this.getFieldsNames(formExpectedFields); - const requriedFieldsNames = this.getFieldsNames(formRequriedFields); - const existsControlsNames = this.getExistsControlsNames(); - - this.setupFormFields( - expectedFieldsNames, - requriedFieldsNames, - existsControlsNames, - ); + const expectedFields = this.getFieldsSettings(formExpectedFields); + const requiredFields = this.getFieldsSettings(formRequriedFields); + const existsControls = this.getExistsControls(); + + this.setupFormFields(expectedFields, requiredFields, existsControls); + this.listenFormInit(); } } @@ -54,48 +53,51 @@ export class PaymentFormComponent extends WebComponentAbstract { return '
'; } + private listenFormInit(): void { + this.headlessCheckout.form.onNextAction((nextAction) => { + if (isShowFieldsAction(nextAction)) { + this.formSpy.formFields = nextAction.data.fields; + this.connectedCallback(); + } + }); + } + private setupFormFields( - expectedFieldsNames: string[], - requiredFieldsNames: string[], - existsControlsNames: Array, + expectedFields: FieldSettings[], + requiredFields: FieldSettings[], + existsControls: FieldSettings[], ): void { - const missedFieldsNames = getMissedFieldsNames( - requiredFieldsNames, - existsControlsNames, - ); - this.setupMissedFields(missedFieldsNames); + const missedFields = getMissedFields(requiredFields, existsControls); + this.setupMissedFields(missedFields); - const invalidFieldsNames = getInvalidFieldsNames( - expectedFieldsNames, - existsControlsNames, - ); - this.setupInvalidFields(invalidFieldsNames); + const invalidFields = getInvalidFields(expectedFields, existsControls); + this.setupInvalidFields(invalidFields); } - private setupMissedFields(missedFieldsNames: string[]): void { + private setupMissedFields(missedFields: FieldSettings[]): void { this.paymentFormFieldsManager.createMissedFields( - missedFieldsNames, + missedFields, this.elementRef, ); - this.logMissedFields(missedFieldsNames); + this.logMissedFields(missedFields); } - private setupInvalidFields(missedFieldsNames: string[]): void { - this.paymentFormFieldsManager.removeExtraFields(missedFieldsNames); + private setupInvalidFields(missedFields: FieldSettings[]): void { + this.paymentFormFieldsManager.removeExtraFields(missedFields); this.paymentFormFieldsManager.removeEmptyNameFields(); - this.logExtraFields(missedFieldsNames); + this.logExtraFields(missedFields); } private getRequriedFields(fields: Field[] = []): Field[] { return fields.filter((field) => field.isMandatory === '1'); } - private getFieldsNames(fields: Field[]): string[] { - return fields.map((field) => field.name); + private getFieldsSettings(fields: Field[]): FieldSettings[] { + return fields.map((field) => ({ name: field.name, type: field.type })); } - private getExistsControlsNames(): Array { - const existsControlsNames: Array = []; + private getExistsControls(): FieldSettings[] { + const existsControlsNames: FieldSettings[] = []; formControlsTags.forEach((tag: WebComponentTagName) => { const formInputs = this.window.document.querySelectorAll(tag); @@ -104,21 +106,30 @@ export class PaymentFormComponent extends WebComponentAbstract { formInput.getAttribute('name'), ); - controlsNames.forEach((name: string | null) => - existsControlsNames.push(name), - ); + controlsNames.forEach((name: string | null) => { + const existField = this.formSpy.formFields?.find( + (field) => field.name === name, + ); + + if (existField) { + existsControlsNames.push({ + name: existField.name, + type: existField.type, + }); + } + }); }); return existsControlsNames; } - private logMissedFields(missedFieldsNames: string[]): void { - if (!missedFieldsNames.length) { + private logMissedFields(missedFields: FieldSettings[]): void { + if (!missedFields.length) { return; } - const message = `This fields were auto created: [${missedFieldsNames.join( - ', ', - )}]. They are mandatory for a payment flow`; + const message = `This fields were auto created: [${missedFields + .map((field) => field.name) + .join(', ')}]. They are mandatory for a payment flow`; console.warn(message); void this.headlessCheckout.events.send<{ message: string }>( { name: EventName.warning, data: { message } }, @@ -126,13 +137,13 @@ export class PaymentFormComponent extends WebComponentAbstract { ); } - private logExtraFields(extraFieldsNames: Array): void { - if (!extraFieldsNames.length) { + private logExtraFields(extraFields: FieldSettings[]): void { + if (!extraFields.length) { return; } - const message = `This fields were auto removed: [${extraFieldsNames.join( - ', ', - )}]. They are useless for a payment flow`; + const message = `This fields were auto removed: [${extraFields + .map((field) => field.name) + .join(', ')}]. They are useless for a payment flow`; console.warn(message); void this.headlessCheckout.events.send<{ message: string }>( { name: EventName.warning, data: { message } }, diff --git a/src/features/headless-checkout/web-components/payment-form/web-components-fields-names.map.ts b/src/features/headless-checkout/web-components/payment-form/web-components-fields-names.map.ts index 66df635..b27c9a0 100644 --- a/src/features/headless-checkout/web-components/payment-form/web-components-fields-names.map.ts +++ b/src/features/headless-checkout/web-components/payment-form/web-components-fields-names.map.ts @@ -4,4 +4,5 @@ export const webComponentsFieldsNamesMap: { [k: string]: WebComponentTagName } = { phone: WebComponentTagName.PhoneComponent, select: WebComponentTagName.SelectComponent, + allowSubscription: WebComponentTagName.CheckboxComponent, }; diff --git a/src/features/headless-checkout/web-components/submit-button/submit-button.component.ts b/src/features/headless-checkout/web-components/submit-button/submit-button.component.ts index cfb1d54..d9e289f 100644 --- a/src/features/headless-checkout/web-components/submit-button/submit-button.component.ts +++ b/src/features/headless-checkout/web-components/submit-button/submit-button.component.ts @@ -9,6 +9,7 @@ import { submitButtonHandler } from './submit-button.handler'; import { Message } from '../../../../core/message.interface'; import { Handler } from '../../../../core/post-messages-client/handler.type'; import { isSubmitButtonLoadingMessage } from '../../../../core/guards/submit-button-loading-message.guard'; +import { isShowFieldsAction } from '../../../../core/actions/is-show-fields-action.function'; export class SubmitButtonComponent extends WebComponentAbstract { private readonly postMessagesClient: PostMessagesClient; @@ -28,6 +29,11 @@ export class SubmitButtonComponent extends WebComponentAbstract { return [SubmitButtonAttributes.isLoading, SubmitButtonAttributes.text]; } + protected connectedCallback(): void { + super.connectedCallback(); + this.listenFormInit(); + } + protected render(): void { this.removeAllEventListeners(); this.innerHTML = this.getHtml(); @@ -81,6 +87,15 @@ export class SubmitButtonComponent extends WebComponentAbstract { return getSubmitButtonTemplate(text, isLoading); } + private listenFormInit(): void { + this.headlessCheckout.form.onNextAction((nextAction) => { + if (isShowFieldsAction(nextAction)) { + this.removeAttribute(SubmitButtonAttributes.isLoading); + this.render(); + } + }); + } + private readonly loadingHandler: Handler = ( message: Message, ): { isHandled: boolean } | null => { diff --git a/src/web-components.ts b/src/web-components.ts index 88203fa..39a233c 100644 --- a/src/web-components.ts +++ b/src/web-components.ts @@ -15,6 +15,7 @@ import { PaymentFormMessagesComponent } from './features/headless-checkout/web-c import { GooglePayButtonComponent } from './features/headless-checkout/web-components/pages/google-pay/google-pay-button.component'; import { QrCodeComponent } from './features/headless-checkout/web-components/qr-code/qr-code.component'; import { ApplePayComponent } from './features/headless-checkout/web-components/apple-pay/apple-pay.component'; +import { PaymentFormComponent } from './features/headless-checkout/web-components/payment-form/payment-form.component'; export { SubmitButtonComponent, @@ -34,4 +35,5 @@ export { GooglePayButtonComponent, QrCodeComponent, ApplePayComponent, + PaymentFormComponent, };