From c622fdf6bd6fd3f3432455b3b14b615cdfa895dd Mon Sep 17 00:00:00 2001 From: "a.kornienko" Date: Tue, 7 May 2024 14:57:56 +0300 Subject: [PATCH] feat(PAYMENTS-19009): add xsolla-number component --- src/core/actions/next-action.interface.ts | 4 +- .../actions/show-cash-payment.action.type.ts | 5 + src/core/cash-payment-data.interface.ts | 9 + src/core/event-name.enum.ts | 4 + ...t-cash-payment-data-event-message.guard.ts | 13 + ...d-cash-payment-data-event-message.guard.ts | 13 + ...send-cash-payment-data-status.interface.ts | 5 + .../web-component-tag-name.enum.ts | 2 + src/core/web-components/web-components.map.ts | 5 + .../get-cash-payment-data.handler.spec.ts | 21 ++ .../get-cash-payment-data.handler.ts | 18 ++ ...d-cash-payment-data-status.handler.spec.ts | 23 ++ .../send-cash-payment-data-status.handler.ts | 23 ++ ...cash-payment-instruction.component.spec.ts | 87 +++++++ .../cash-payment-instruction.component.ts | 56 +++++ .../cash-payment.type.ts | 3 + .../send-button-status.interface.ts | 4 + .../templates/get-instruction.template.ts | 28 +++ .../templates/get-notifier.template.ts | 32 +++ .../get-payment-description.template.ts | 9 + .../templates/get-payment-info.template.ts | 22 ++ .../get-send-number-panel.template.ts | 38 +++ .../get-xsolla-number.compontent.template.ts | 28 +++ ...solla-number-component.config.interface.ts | 9 + .../xsolla-number-send.handler.ts | 14 ++ .../xsolla-number.component.scss | 206 ++++++++++++++++ .../xsolla-number.component.spec.ts | 119 +++++++++ .../xsolla-number/xsolla-number.component.ts | 225 ++++++++++++++++++ src/styles/mixins/buttons.mixin.scss | 25 ++ src/styles/style.scss | 3 +- src/styles/themes/default/variables.scss | 1 + src/tests/stubs/timeout.ts | 2 + src/translations/en.json | 19 +- src/web-components.ts | 2 + 34 files changed, 1074 insertions(+), 3 deletions(-) create mode 100644 src/core/actions/show-cash-payment.action.type.ts create mode 100644 src/core/cash-payment-data.interface.ts create mode 100644 src/core/guards/get-cash-payment-data/get-cash-payment-data-event-message.guard.ts create mode 100644 src/core/guards/send-cash-payment-data-status/send-cash-payment-data-event-message.guard.ts create mode 100644 src/core/send-cash-payment-data-status.interface.ts create mode 100644 src/features/headless-checkout/post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler.spec.ts create mode 100644 src/features/headless-checkout/post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler.ts create mode 100644 src/features/headless-checkout/post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler.spec.ts create mode 100644 src/features/headless-checkout/post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler.ts create mode 100644 src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component.spec.ts create mode 100644 src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component.ts create mode 100644 src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment.type.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/send-button-status.interface.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/templates/get-instruction.template.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/templates/get-notifier.template.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/templates/get-payment-description.template.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/templates/get-payment-info.template.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/templates/get-send-number-panel.template.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/templates/get-xsolla-number.compontent.template.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/xsolla-number-component.config.interface.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/xsolla-number-send.handler.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.scss create mode 100644 src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.spec.ts create mode 100644 src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.ts create mode 100644 src/styles/mixins/buttons.mixin.scss create mode 100644 src/tests/stubs/timeout.ts diff --git a/src/core/actions/next-action.interface.ts b/src/core/actions/next-action.interface.ts index 7838dba..83405cc 100644 --- a/src/core/actions/next-action.interface.ts +++ b/src/core/actions/next-action.interface.ts @@ -8,6 +8,7 @@ import { SpecialButtonAction } from './special-button.action.type'; import { ShowQrCodeAction } from './show-qr-code.action.type'; import { ShowMobilePaymentScreenAction } from './show-mobile-payment-screen.action.type'; import { HideFormAction } from './hide-form.action.type'; +import { ShowCashPaymentAction } from './show-cash-payment.action.type'; export type NextAction = | CheckStatusAction @@ -19,4 +20,5 @@ export type NextAction = | SpecialButtonAction | ShowQrCodeAction | ShowMobilePaymentScreenAction - | HideFormAction; + | HideFormAction + | ShowCashPaymentAction; diff --git a/src/core/actions/show-cash-payment.action.type.ts b/src/core/actions/show-cash-payment.action.type.ts new file mode 100644 index 0000000..41f5216 --- /dev/null +++ b/src/core/actions/show-cash-payment.action.type.ts @@ -0,0 +1,5 @@ +import { Action } from './action.interface'; + +export type ShowCashPaymentActionType = 'show_cash_payment_instruction'; + +export type ShowCashPaymentAction = Action; diff --git a/src/core/cash-payment-data.interface.ts b/src/core/cash-payment-data.interface.ts new file mode 100644 index 0000000..99fff40 --- /dev/null +++ b/src/core/cash-payment-data.interface.ts @@ -0,0 +1,9 @@ +export interface CashPaymentData { + isCashPaymentMethod: boolean; + xsollaNumber: string; + pid: number; + publicId: string; + title: string; + projectName: string; + printUrl: string; +} diff --git a/src/core/event-name.enum.ts b/src/core/event-name.enum.ts index cbf3c2d..98dc143 100644 --- a/src/core/event-name.enum.ts +++ b/src/core/event-name.enum.ts @@ -34,4 +34,8 @@ export const enum EventName { openApplePayPage = 'openApplePayPage', submitApplePayForm = 'submitApplePayForm', userCountryChanged = 'userCountryChanged', + getCashPaymentData = 'getCashPaymentData', + sendCashPaymentData = 'sendCashPaymentData', + sendCashPaymentButtonStatus = 'sendCashPaymentButtonStatus', + sendCashPaymentDataStatus = 'sendCashPaymentDataStatus', } diff --git a/src/core/guards/get-cash-payment-data/get-cash-payment-data-event-message.guard.ts b/src/core/guards/get-cash-payment-data/get-cash-payment-data-event-message.guard.ts new file mode 100644 index 0000000..c66c5b3 --- /dev/null +++ b/src/core/guards/get-cash-payment-data/get-cash-payment-data-event-message.guard.ts @@ -0,0 +1,13 @@ +import { Message } from '../../message.interface'; +import { CashPaymentData } from '../../cash-payment-data.interface'; +import { isEventMessage } from '../event-message.guard'; +import { EventName } from '../../event-name.enum'; + +export const isGetCashPaymentDataEventMessage = ( + messageData: unknown, +): messageData is Message => { + if (isEventMessage(messageData)) { + return messageData.name === EventName.getCashPaymentData; + } + return false; +}; diff --git a/src/core/guards/send-cash-payment-data-status/send-cash-payment-data-event-message.guard.ts b/src/core/guards/send-cash-payment-data-status/send-cash-payment-data-event-message.guard.ts new file mode 100644 index 0000000..075a103 --- /dev/null +++ b/src/core/guards/send-cash-payment-data-status/send-cash-payment-data-event-message.guard.ts @@ -0,0 +1,13 @@ +import { Message } from '../../message.interface'; +import { isEventMessage } from '../event-message.guard'; +import { EventName } from '../../event-name.enum'; +import { SendCashPaymentDataStatus } from '../../send-cash-payment-data-status.interface'; + +export const isSentCashPaymentDataStatus = ( + messageData: unknown, +): messageData is Message => { + if (isEventMessage(messageData)) { + return messageData.name === EventName.sendCashPaymentDataStatus; + } + return false; +}; diff --git a/src/core/send-cash-payment-data-status.interface.ts b/src/core/send-cash-payment-data-status.interface.ts new file mode 100644 index 0000000..bc169b5 --- /dev/null +++ b/src/core/send-cash-payment-data-status.interface.ts @@ -0,0 +1,5 @@ +export interface SendCashPaymentDataStatus { + status: 'succeed' | 'failed'; + type: 'sms' | 'email'; + errors: string[]; +} diff --git a/src/core/web-components/web-component-tag-name.enum.ts b/src/core/web-components/web-component-tag-name.enum.ts index 11a98ce..0c04251 100644 --- a/src/core/web-components/web-component-tag-name.enum.ts +++ b/src/core/web-components/web-component-tag-name.enum.ts @@ -20,4 +20,6 @@ export enum WebComponentTagName { GooglePayButtonComponent = 'psdk-google-pay-button', ApplePayComponent = 'psdk-apple-pay', QrCodeComponent = 'psdk-qr-code', + CashPaymentInstructionComponent = 'psdk-cash-payment-instruction', + XsollaNumberComponent = 'psdk-xsolla-number', } diff --git a/src/core/web-components/web-components.map.ts b/src/core/web-components/web-components.map.ts index a382e71..eb45b3a 100644 --- a/src/core/web-components/web-components.map.ts +++ b/src/core/web-components/web-components.map.ts @@ -20,6 +20,8 @@ import { QrCodeComponent } from '../../features/headless-checkout/web-components import { ApplePayComponent } from '../../features/headless-checkout/web-components/apple-pay/apple-pay.component'; import { DefaultSubmitButtonComponent } from '../../features/headless-checkout/web-components/submit-button/default-submit-button/default-submit-button.component'; import { TotalComponent } from '../../features/headless-checkout/web-components/finance-details/total.component'; +import { CashPaymentInstructionComponent } from '../../features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component'; +import { XsollaNumberComponent } from '../../features/headless-checkout/web-components/xsolla-number/xsolla-number.component'; export const webComponents: { [key in WebComponentTagName]: CustomElementConstructor; @@ -46,4 +48,7 @@ export const webComponents: { [WebComponentTagName.ApplePayComponent]: ApplePayComponent, [WebComponentTagName.DefaultSubmitButtonComponent]: DefaultSubmitButtonComponent, + [WebComponentTagName.CashPaymentInstructionComponent]: + CashPaymentInstructionComponent, + [WebComponentTagName.XsollaNumberComponent]: XsollaNumberComponent, }; diff --git a/src/features/headless-checkout/post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler.spec.ts b/src/features/headless-checkout/post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler.spec.ts new file mode 100644 index 0000000..03a8ec0 --- /dev/null +++ b/src/features/headless-checkout/post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler.spec.ts @@ -0,0 +1,21 @@ +import { EventName } from '../../../../core/event-name.enum'; +import { Message } from '../../../../core/message.interface'; +import { getCashPaymentDataHandler } from './get-cash-payment-data.handler'; + +const mockMessage: Message = { + name: EventName.getCashPaymentData, +}; +describe('getCashPaymentDataHandler', () => { + it('should handle status', () => { + expect(getCashPaymentDataHandler(mockMessage)).toEqual({ + isHandled: true, + value: undefined, + }); + }); + + it('should return null', () => { + expect( + getCashPaymentDataHandler({ name: EventName.getPaymentMethodsList }), + ).toBeNull(); + }); +}); diff --git a/src/features/headless-checkout/post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler.ts b/src/features/headless-checkout/post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler.ts new file mode 100644 index 0000000..6aa61b8 --- /dev/null +++ b/src/features/headless-checkout/post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler.ts @@ -0,0 +1,18 @@ +import { Handler } from '../../../../core/post-messages-client/handler.type'; +import { Message } from '../../../../core/message.interface'; +import { CashPaymentType } from '../../web-components/cash-payment-instruction/cash-payment.type'; +import { isGetCashPaymentDataEventMessage } from '../../../../core/guards/get-cash-payment-data/get-cash-payment-data-event-message.guard'; + +export const getCashPaymentDataHandler: Handler = ( + message: Message, +): { isHandled: boolean; value: CashPaymentType | null | undefined } | null => { + if (!isGetCashPaymentDataEventMessage(message)) { + return null; + } + + const cashPaymentData = message.data; + return { + isHandled: true, + value: cashPaymentData, + }; +}; diff --git a/src/features/headless-checkout/post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler.spec.ts b/src/features/headless-checkout/post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler.spec.ts new file mode 100644 index 0000000..0cd46dd --- /dev/null +++ b/src/features/headless-checkout/post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler.spec.ts @@ -0,0 +1,23 @@ +import { EventName } from '../../../../core/event-name.enum'; +import { Message } from '../../../../core/message.interface'; +import { sendCashPaymentDataStatusHandler } from './send-cash-payment-data-status.handler'; + +const mockMessage: Message = { + name: EventName.sendCashPaymentDataStatus, +}; +describe('sendCashPaymentDataStatusHandler', () => { + it('should handle status', () => { + expect(sendCashPaymentDataStatusHandler(mockMessage)).toEqual({ + isHandled: true, + value: undefined, + }); + }); + + it('should return null', () => { + expect( + sendCashPaymentDataStatusHandler({ + name: EventName.getPaymentMethodsList, + }), + ).toBeNull(); + }); +}); diff --git a/src/features/headless-checkout/post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler.ts b/src/features/headless-checkout/post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler.ts new file mode 100644 index 0000000..c4991ba --- /dev/null +++ b/src/features/headless-checkout/post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler.ts @@ -0,0 +1,23 @@ +import { Handler } from '../../../../core/post-messages-client/handler.type'; +import { Message } from '../../../../core/message.interface'; +import { isSentCashPaymentDataStatus } from '../../../../core/guards/send-cash-payment-data-status/send-cash-payment-data-event-message.guard'; +import { SendCashPaymentDataStatus } from '../../../../core/send-cash-payment-data-status.interface'; + +export const sendCashPaymentDataStatusHandler: Handler< + SendCashPaymentDataStatus | null | undefined +> = ( + message: Message, +): { + isHandled: boolean; + value: SendCashPaymentDataStatus | null | undefined; +} | null => { + if (!isSentCashPaymentDataStatus(message)) { + return null; + } + + const cashPaymentData = message.data; + return { + isHandled: true, + value: cashPaymentData, + }; +}; diff --git a/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component.spec.ts b/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component.spec.ts new file mode 100644 index 0000000..76c10e8 --- /dev/null +++ b/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component.spec.ts @@ -0,0 +1,87 @@ +import { container } from 'tsyringe'; +import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; +import { noopStub } from '../../../../tests/stubs/noop.stub'; +import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { PostMessagesClient } from '../../../../core/post-messages-client/post-messages-client'; +import { CashPaymentData } from '../../../../core/cash-payment-data.interface'; +import { CashPaymentInstructionComponent } from './cash-payment-instruction.component'; +import { timeout } from '../../../../tests/stubs/timeout'; + +function createComponent(): void { + const element = document.createElement( + WebComponentTagName.CashPaymentInstructionComponent, + ); + (document.getElementById('container')! as HTMLElement).appendChild(element); +} + +const cashPaymentData = { + isCashPaymentMethod: false, +} as CashPaymentData; + +describe('CashPaymentInstructionComponent', () => { + let formSpy: FormSpy; + let postMessagesClient: PostMessagesClient; + + window.customElements.define( + WebComponentTagName.CashPaymentInstructionComponent, + CashPaymentInstructionComponent, + ); + + beforeEach(() => { + document.body.innerHTML = '
'; + + postMessagesClient = { + init: noopStub, + send: noopStub, + listen: noopStub, + } as unknown as PostMessagesClient; + + formSpy = { + listenFormInit: noopStub, + get formWasInit() { + return; + }, + } as unknown as FormSpy; + + container.clearInstances(); + + container + .register(FormSpy, { + useValue: formSpy, + }) + .register(PostMessagesClient, { + useValue: postMessagesClient, + }) + .register(Window, { useValue: window }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('Should draw cash payment component', () => { + spyOnProperty(formSpy, 'formWasInit').and.returnValue(true); + spyOn(postMessagesClient, 'send').and.returnValue(Promise.resolve(null)); + createComponent(); + expect( + document.querySelector( + WebComponentTagName.CashPaymentInstructionComponent, + )!.innerHTML, + ).not.toContain(WebComponentTagName.XsollaNumberComponent); + }); + + it('Should draw XsollaNumberComponent', async () => { + spyOnProperty(formSpy, 'formWasInit').and.returnValue(true); + spyOn(postMessagesClient, 'send').and.returnValue( + Promise.resolve(cashPaymentData), + ); + createComponent(); + const delay = 100; + await timeout(delay); + expect( + document.querySelector( + WebComponentTagName.CashPaymentInstructionComponent, + )!.innerHTML, + ).toContain(WebComponentTagName.XsollaNumberComponent); + }); +}); diff --git a/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component.ts b/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component.ts new file mode 100644 index 0000000..f24f4b7 --- /dev/null +++ b/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component.ts @@ -0,0 +1,56 @@ +import { container } from 'tsyringe'; +import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { EventName } from '../../../../core/event-name.enum'; +import { PostMessagesClient } from '../../../../core/post-messages-client/post-messages-client'; +import { Message } from '../../../../core/message.interface'; +import { getCashPaymentDataHandler } from '../../post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler'; +import { CashPaymentData } from '../../../../core/cash-payment-data.interface'; +import { CashPaymentType } from './cash-payment.type'; +import { WebComponentAbstract } from '../../../../core/web-components/web-component.abstract'; +import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; + +export class CashPaymentInstructionComponent extends WebComponentAbstract { + private readonly formSpy: FormSpy; + private readonly postMessagesClient: PostMessagesClient; + private cashPaymentData?: CashPaymentData | null; + public constructor() { + super(); + this.formSpy = container.resolve(FormSpy); + this.postMessagesClient = container.resolve(PostMessagesClient); + } + + protected connectedCallback(): void { + if (!this.formSpy.formWasInit) { + this.formSpy.listenFormInit(() => this.connectedCallback()); + return; + } + + void this.getCashPaymentData().then(this.configLoadedHandler); + } + + protected getHtml(): string { + if (this.cashPaymentData?.isCashPaymentMethod) { + return ''; + } + + return `<${WebComponentTagName.XsollaNumberComponent}>`; + } + + private async getCashPaymentData(): Promise { + const msg: Message = { + name: EventName.getCashPaymentData, + }; + + return this.postMessagesClient.send( + msg, + getCashPaymentDataHandler, + ) as Promise; + } + + private readonly configLoadedHandler = ( + cashPaymentData: CashPaymentType, + ): void => { + this.cashPaymentData = cashPaymentData; + super.render(); + }; +} diff --git a/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment.type.ts b/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment.type.ts new file mode 100644 index 0000000..146e88b --- /dev/null +++ b/src/features/headless-checkout/web-components/cash-payment-instruction/cash-payment.type.ts @@ -0,0 +1,3 @@ +import { CashPaymentData } from '../../../../core/cash-payment-data.interface'; + +export type CashPaymentType = CashPaymentData | null; diff --git a/src/features/headless-checkout/web-components/xsolla-number/send-button-status.interface.ts b/src/features/headless-checkout/web-components/xsolla-number/send-button-status.interface.ts new file mode 100644 index 0000000..9beb776 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/send-button-status.interface.ts @@ -0,0 +1,4 @@ +export interface SendButtonStatus { + channelType: 'phone' | 'email'; + isDisabled: boolean; +} diff --git a/src/features/headless-checkout/web-components/xsolla-number/templates/get-instruction.template.ts b/src/features/headless-checkout/web-components/xsolla-number/templates/get-instruction.template.ts new file mode 100644 index 0000000..5abe8f2 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/templates/get-instruction.template.ts @@ -0,0 +1,28 @@ +import i18next from 'i18next'; + +export const getInstructionTemplate = ( + paymentMethodName: string, + projectName?: string, +): string => { + return `
+

${i18next.t('xsolla-number.instruction.how-to')}

+
    +
  • ${i18next.t('xsolla-number.instruction.paragraph-one', { + paymentMethodName, + })}
  • +
  • + ${i18next.t('xsolla-number.instruction.paragraph-two', { projectName })} +
  • +
  • + ${i18next.t('xsolla-number.instruction.paragraph-three')} +
  • +
  • + ${i18next.t('xsolla-number.instruction.paragraph-four')} +
  • +
+ +

+ ${i18next.t('xsolla-number.instruction.notification')} +

+
`; +}; diff --git a/src/features/headless-checkout/web-components/xsolla-number/templates/get-notifier.template.ts b/src/features/headless-checkout/web-components/xsolla-number/templates/get-notifier.template.ts new file mode 100644 index 0000000..c1a5e16 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/templates/get-notifier.template.ts @@ -0,0 +1,32 @@ +import { SendCashPaymentDataStatus } from '../../../../../core/send-cash-payment-data-status.interface'; +import i18next from 'i18next'; +const closeButtonIcon = ` + + +`; +export const getNotifierTemplate = ( + sendStatus: SendCashPaymentDataStatus, +): string => { + let notification; + if (sendStatus.status === 'succeed') { + notification = + sendStatus.type === 'email' + ? i18next.t('xsolla-number.notification.success.email') + : i18next.t('xsolla-number.notification.success.sms'); + } else { + notification = sendStatus.errors?.length + ? sendStatus.errors[0] + : i18next.t('xsolla-number.notification.failed'); + } + return ` +
+

+ ${notification} + +

+ +
+`; +}; diff --git a/src/features/headless-checkout/web-components/xsolla-number/templates/get-payment-description.template.ts b/src/features/headless-checkout/web-components/xsolla-number/templates/get-payment-description.template.ts new file mode 100644 index 0000000..a03a771 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/templates/get-payment-description.template.ts @@ -0,0 +1,9 @@ +import i18next from 'i18next'; + +export const getPaymentDescription = (paymentMethodName?: string): string => ` +

+${i18next.t('xsolla-number.payment.title', { paymentMethodName })}

+

+${i18next.t('xsolla-number.payment.description')} +

+`; diff --git a/src/features/headless-checkout/web-components/xsolla-number/templates/get-payment-info.template.ts b/src/features/headless-checkout/web-components/xsolla-number/templates/get-payment-info.template.ts new file mode 100644 index 0000000..46f594b --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/templates/get-payment-info.template.ts @@ -0,0 +1,22 @@ +import i18next from 'i18next'; + +export const getPaymentInfoTemplate = ( + xsollaNumber: string, + userName: string = '', +): string => { + return ` +
+
+
+ ${i18next.t('xsolla-number.info.user-account')} +
+
${userName}
+
+
+
+ ${i18next.t('xsolla-number.info.xsolla-number')} +
+
${xsollaNumber}
+
+
`; +}; diff --git a/src/features/headless-checkout/web-components/xsolla-number/templates/get-send-number-panel.template.ts b/src/features/headless-checkout/web-components/xsolla-number/templates/get-send-number-panel.template.ts new file mode 100644 index 0000000..0b6ed43 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/templates/get-send-number-panel.template.ts @@ -0,0 +1,38 @@ +import i18next from 'i18next'; + +export const getSendNumberPanelTemplate = ( + emailControl: string, + phoneControl: string, + printUrl?: string, +): string => { + return `
+
+ +
+
${phoneControl}
+ +
+
+ + +
`; +}; diff --git a/src/features/headless-checkout/web-components/xsolla-number/templates/get-xsolla-number.compontent.template.ts b/src/features/headless-checkout/web-components/xsolla-number/templates/get-xsolla-number.compontent.template.ts new file mode 100644 index 0000000..ed79c92 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/templates/get-xsolla-number.compontent.template.ts @@ -0,0 +1,28 @@ +import { XsollaNumberComponentConfig } from '../xsolla-number-component.config.interface'; +import { getInstructionTemplate } from './get-instruction.template'; +import { getPaymentInfoTemplate } from './get-payment-info.template'; +import { getSendNumberPanelTemplate } from './get-send-number-panel.template'; +import { getPaymentDescription } from './get-payment-description.template'; + +export const getXsollaNumberComponentTemplate = ({ + emailControl, + phoneControl, + paymentMethod, + xsollaNumber, + userName, + printUrl, + projectName, +}: XsollaNumberComponentConfig): string => { + let template = ''; + + template += getPaymentDescription(paymentMethod); + if (xsollaNumber) { + template += getPaymentInfoTemplate(xsollaNumber, userName); + } + if (paymentMethod) { + template += getInstructionTemplate(paymentMethod, projectName); + } + template += getSendNumberPanelTemplate(emailControl, phoneControl, printUrl); + template += `
`; + return template; +}; diff --git a/src/features/headless-checkout/web-components/xsolla-number/xsolla-number-component.config.interface.ts b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number-component.config.interface.ts new file mode 100644 index 0000000..d18a692 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number-component.config.interface.ts @@ -0,0 +1,9 @@ +export interface XsollaNumberComponentConfig { + emailControl: string; + phoneControl: string; + paymentMethod?: string; + userName?: string; + xsollaNumber?: string; + printUrl?: string; + projectName?: string; +} diff --git a/src/features/headless-checkout/web-components/xsolla-number/xsolla-number-send.handler.ts b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number-send.handler.ts new file mode 100644 index 0000000..0d548a3 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number-send.handler.ts @@ -0,0 +1,14 @@ +import { Handler } from '../../../../core/post-messages-client/handler.type'; +import { Message } from '../../../../core/message.interface'; +import { EventName } from '../../../../core/event-name.enum'; + +export const xsollaNumberSendHandler: Handler = ( + message: Message, +): { isHandled: boolean } | null => { + if (message.name === EventName.sendCashPaymentData) { + return { + isHandled: true, + }; + } + return null; +}; diff --git a/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.scss b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.scss new file mode 100644 index 0000000..54e657d --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.scss @@ -0,0 +1,206 @@ +@use 'src/styles/mixins/typo.mixin' as typo; +@use 'src/styles/mixins/animation.mixin' as animation; +@use 'src/styles/mixins/buttons.mixin' as buttons; + +psdk-xsolla-number { + @include typo.psdk-typo; + + .payment-title { + @include typo.psdk-title2; + + margin: 0 0 4px; + padding: 0; + } + + .payment-description { + margin: 0 0 20px; + padding: 0; + } + + .payment-info { + display: flex; + justify-content: space-between; + width: 100%; + margin: 0 0 20px; + + .item { + $gap: 24px; + + flex-shrink: 0; + width: calc(50% - calc($gap / 2)); + + .content { + @include typo.psdk-title2; + } + } + } + + .instruction-wrapper { + margin-bottom: 20px; + + .title { + @include typo.psdk-title2; + + margin: 0 0 4px; + padding: 0; + } + + .instruction { + display: flex; + flex-direction: column; + margin: 0 0 4px; + padding: 0; + list-style: none; + } + + .notifier { + margin: 0; + padding: 12px; + border-radius: var(--psdk-common-border-radius); + background: var(--psdk-warning-bg); + color: var(--psdk-warning-color); + } + } + + .send-xsolla-number-panel { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; + width: 100%; + margin-bottom: 16px; + + .choose-method-button { + @include typo.psdk-title2; + + margin: 0; + padding: 0; + border: none; + background: transparent; + color: var(--psdk-button-default-bg); + text-decoration: none; + cursor: pointer; + } + + .wrapper { + margin-right: 10px; + margin-bottom: 0; + } + + .recipient { + position: absolute; + display: flex; + align-items: center; + justify-content: flex-start; + width: 0; + height: 0; + visibility: hidden; + + button { + display: none; + } + } + + .active-control { + .recipient { + position: static; + width: auto; + height: auto; + visibility: visible; + + button { + display: block; + } + } + + .choose-method-button { + margin-bottom: 3px; + color: #000; + font-weight: 400; + } + } + + .send-button { + @include typo.psdk-typo; + @include buttons.default-button; + + position: relative; + width: auto; + padding: 11px 14px; + + .text { + visibility: visible; + pointer-events: none; + } + + .loader { + @include animation.loader(var(--psdk-button-color)); + + position: absolute; + top: 0; + right: 0; + left: 0; + display: none; + } + + &.is-loading { + cursor: default; + + .loader { + display: flex; + } + + .text { + visibility: hidden; + } + } + } + } + + .send-notification { + position: relative; + margin: 0; + padding: 12px 48px 12px 12px; + border-radius: var(--psdk-common-border-radius); + } + + .send-notification-failed { + background: var(--psdk-alert-bg); + color: var(--psdk-alert-color); + + & svg { + fill: var(--psdk-alert-color); + } + } + + .send-notification-succeed { + background: var(--psdk-success-bg); + color: var(--psdk-success-color); + + & svg { + fill: var(--psdk-success-color); + } + } + + .psdk-close-button { + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + margin: 0; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + + & svg { + pointer-events: none; + } + } + + .notification-description { + margin: 0; + padding: 0; + } +} diff --git a/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.spec.ts b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.spec.ts new file mode 100644 index 0000000..a73a839 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.spec.ts @@ -0,0 +1,119 @@ +import { container } from 'tsyringe'; +import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; +import { noopStub } from '../../../../tests/stubs/noop.stub'; +import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { PostMessagesClient } from '../../../../core/post-messages-client/post-messages-client'; +import { CashPaymentData } from '../../../../core/cash-payment-data.interface'; +import { timeout } from '../../../../tests/stubs/timeout'; +import { XsollaNumberComponent } from './xsolla-number.component'; +import { HeadlessCheckoutMock } from '../../../../tests/stubs/headless-checkout.mock'; +import { HeadlessCheckout } from '../../headless-checkout'; +import { EventName } from '../../../../core/event-name.enum'; + +function createComponent(): void { + const element = document.createElement( + WebComponentTagName.XsollaNumberComponent, + ); + (document.getElementById('container')! as HTMLElement).appendChild(element); +} + +const cashPaymentData = { + isCashPaymentMethod: false, + xsollaNumber: '123', + pid: 19, + publicId: 'mock_publicId', + title: 'mock_title', + projectName: 'mock_projectName', + printUrl: 'mock_printUrl', +} as CashPaymentData; + +describe('XsollaNumberComponent', () => { + let formSpy: FormSpy; + let postMessagesClient: PostMessagesClient; + let headlessCheckout: HeadlessCheckoutMock; + + window.customElements.define( + WebComponentTagName.XsollaNumberComponent, + XsollaNumberComponent, + ); + + beforeEach(() => { + document.body.innerHTML = '
'; + + postMessagesClient = { + init: noopStub, + send: noopStub, + listen: noopStub, + } as unknown as PostMessagesClient; + + formSpy = { + listenFormInit: noopStub, + get formWasInit() { + return; + }, + } as unknown as FormSpy; + + headlessCheckout = new HeadlessCheckoutMock(); + + container.clearInstances(); + + container + .register(FormSpy, { + useValue: formSpy, + }) + .register(PostMessagesClient, { + useValue: postMessagesClient, + }) + .register(Window, { useValue: window }) + .register(HeadlessCheckout, { + useValue: headlessCheckout as unknown as HeadlessCheckout, + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('Should draw cash payment component', async () => { + spyOnProperty(formSpy, 'formWasInit').and.returnValue(true); + spyOn(postMessagesClient, 'send').and.returnValue( + Promise.resolve(cashPaymentData), + ); + createComponent(); + const delay = 100; + await timeout(delay); + const innerHtml = document.querySelector( + WebComponentTagName.XsollaNumberComponent, + )!.innerHTML; + expect(innerHtml).toContain('payment-title'); + expect(innerHtml).toContain('payment-info'); + expect(innerHtml).toContain('instruction-wrapper'); + expect(innerHtml).toContain('send-xsolla-number-panel'); + }); + + it('Should draw notification', (done) => { + spyOnProperty(formSpy, 'formWasInit').and.returnValue(true); + spyOn(postMessagesClient, 'send').and.returnValue( + Promise.resolve(cashPaymentData), + ); + spyOn(headlessCheckout.events, 'onCoreEvent').and.callFake((...args) => { + const eventName = args[0]; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const callback: (value?: unknown) => void = args[2]; + setTimeout(() => { + if (eventName === EventName.sendCashPaymentDataStatus) { + callback({ status: 'succeed', type: 'sms', error: [] }); + expect( + document.querySelector(WebComponentTagName.XsollaNumberComponent)! + .innerHTML, + ).toContain('send-notification'); + done(); + } + }); + return noopStub; + }); + + createComponent(); + }); +}); diff --git a/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.ts b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.ts new file mode 100644 index 0000000..3a548b7 --- /dev/null +++ b/src/features/headless-checkout/web-components/xsolla-number/xsolla-number.component.ts @@ -0,0 +1,225 @@ +import { SecureComponentAbstract } from '../../../../core/web-components/secure-component/secure-component.abstract'; +import { container } from 'tsyringe'; +import { HeadlessCheckout } from '../../headless-checkout'; +import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { EventName } from '../../../../core/event-name.enum'; +import { CashPaymentType } from '../cash-payment-instruction/cash-payment.type'; +import { sendCashPaymentDataStatusHandler } from '../../post-messages-handlers/send-cash-payment-data-status/send-cash-payment-data-status.handler'; +import { SendCashPaymentDataStatus } from '../../../../core/send-cash-payment-data-status.interface'; +import { getNotifierTemplate } from './templates/get-notifier.template'; +import { Message } from '../../../../core/message.interface'; +import { isEventMessage } from '../../../../core/guards/event-message.guard'; +import { CashPaymentData } from '../../../../core/cash-payment-data.interface'; +import { getCashPaymentDataHandler } from '../../post-messages-handlers/get-cash-payment-data/get-cash-payment-data.handler'; +import { PostMessagesClient } from '../../../../core/post-messages-client/post-messages-client'; +import { headlessCheckoutAppUrl } from '../../environment'; +import { getXsollaNumberComponentTemplate } from './templates/get-xsolla-number.compontent.template'; +import './xsolla-number.component.scss'; +import { SendButtonStatus } from './send-button-status.interface'; + +export class XsollaNumberComponent extends SecureComponentAbstract { + protected componentName = 'xsolla-number'; + protected inputName = 'xsolla-number'; + private readonly headlessCheckout: HeadlessCheckout; + private readonly formSpy: FormSpy; + private cashPaymentData?: CashPaymentData | null; + private readonly window: Window; + private readonly postMessagesClient: PostMessagesClient; + private isLoading = false; + + public constructor() { + super(); + this.headlessCheckout = container.resolve(HeadlessCheckout); + this.formSpy = container.resolve(FormSpy); + this.window = container.resolve(Window); + this.postMessagesClient = container.resolve(PostMessagesClient); + } + + protected connectedCallback(): void { + if (!this.formSpy.formWasInit) { + this.formSpy.listenFormInit(() => this.connectedCallback()); + return; + } + + void this.getCashPaymentData().then(this.configLoadedHandler); + } + + protected async getCashPaymentData(): Promise { + const msg: Message = { + name: EventName.getCashPaymentData, + }; + + return this.postMessagesClient.send( + msg, + getCashPaymentDataHandler, + ) as Promise; + } + + protected getHtml(): string { + const emailControl = ``; + const phoneControl = ``; + const title = this.cashPaymentData?.title; + const userName = this.cashPaymentData?.publicId; + const xsollaNumber = this.cashPaymentData?.xsollaNumber; + const printUrl = this.cashPaymentData?.printUrl; + const projectName = this.cashPaymentData?.projectName; + return getXsollaNumberComponentTemplate({ + emailControl, + phoneControl, + userName, + paymentMethod: title, + xsollaNumber, + printUrl, + projectName, + }); + } + + protected disconnectedCallback(): void { + super.disconnectedCallback(); + this.window.removeEventListener('message', this.changeButtonStatusCallback); + } + + private readonly configLoadedHandler = ( + cashPaymentData: CashPaymentType, + ): void => { + this.cashPaymentData = cashPaymentData; + super.render(); + void this.headlessCheckout.events.onCoreEvent( + EventName.sendCashPaymentDataStatus, + sendCashPaymentDataStatusHandler, + (status) => this.sendPaymentInstructionStatusCallback(status), + ); + this.listenSendButtonsStatusChange(); + this.listenComponentsClicks(); + }; + + private listenComponentsClicks(): void { + this.addEventListenerToElement(this, 'click', (event) => { + switch ((event.target as HTMLElement).id) { + case 'send-email': + if (this.isLoading) { + return; + } + this.setupLoading('email', true); + void this.sendPaymentInstruction('email'); + break; + case 'send-sms': + if (this.isLoading) { + return; + } + this.setupLoading('phone', true); + void this.sendPaymentInstruction('phone'); + break; + case 'close-notification': + void this.closeNotification(); + break; + case 'phone-recipient-button': + this.querySelector('.phone-control-wrapper')!.classList.add( + 'active-control', + ); + this.querySelector('.email-control-wrapper')!.classList.remove( + 'active-control', + ); + break; + case 'email-recipient-button': + this.querySelector('.phone-control-wrapper')!.classList.remove( + 'active-control', + ); + this.querySelector('.email-control-wrapper')!.classList.add( + 'active-control', + ); + break; + } + }); + } + + private setupLoading( + channelType: 'phone' | 'email', + isLoading: boolean, + ): void { + this.isLoading = isLoading; + const button = this.querySelector(`#send-${channelType}`); + if (!button) { + return; + } + + if (isLoading) { + button.classList.add('is-loading'); + return; + } + button.classList.remove('is-loading'); + } + + private readonly sendPaymentInstructionStatusCallback = ( + status?: SendCashPaymentDataStatus | null, + ): void => { + if (!status) { + return; + } + + this.showNotification(status); + this.setupLoading(status.type === 'sms' ? 'phone' : 'email', false); + }; + + private sendPaymentInstruction(channelType: 'phone' | 'email'): void { + const recipient = this.querySelector( + `#${channelType}-recipient-container iframe`, + )!; + + if (!recipient) { + return; + } + const msg: Message<{ channelType: 'phone' | 'email' }> = { + name: EventName.sendCashPaymentData, + data: { + channelType: channelType, + }, + }; + + return (recipient as HTMLIFrameElement).contentWindow?.postMessage( + JSON.stringify(msg), + (recipient as HTMLIFrameElement)?.src, + ); + } + + private listenSendButtonsStatusChange(): void { + this.window.addEventListener('message', this.changeButtonStatusCallback); + } + + private readonly changeButtonStatusCallback = ( + message: MessageEvent, + ): void => { + const messageData = this.getJsonOrNull(message?.data); + if ( + !messageData || + !isEventMessage(messageData) || + messageData.name !== EventName.sendCashPaymentButtonStatus + ) { + return; + } + const data = messageData.data as SendButtonStatus; + this.setupSendButtonsDisabled(data); + }; + + private setupSendButtonsDisabled(data: SendButtonStatus): void { + if (data.channelType === 'email') { + (this.querySelector('#send-email')! as HTMLButtonElement).disabled = + data.isDisabled; + } + if (data.channelType === 'phone') { + (this.querySelector('#send-sms')! as HTMLButtonElement).disabled = + data.isDisabled; + } + } + + private showNotification(status: SendCashPaymentDataStatus): void { + const notifierContainer = this.querySelector('#send-status-container')!; + + notifierContainer.innerHTML = getNotifierTemplate(status); + } + + private closeNotification(): void { + const notifierContainer = this.querySelector('#send-status-container')!; + notifierContainer.innerHTML = ''; + } +} diff --git a/src/styles/mixins/buttons.mixin.scss b/src/styles/mixins/buttons.mixin.scss new file mode 100644 index 0000000..c038aac --- /dev/null +++ b/src/styles/mixins/buttons.mixin.scss @@ -0,0 +1,25 @@ +@mixin default-button { + width: 100%; + max-width: 360px; + height: 40px; + border: none; + border-radius: var(--psdk-button-border-radius); + background: var(--psdk-button-default-bg); + color: var(--psdk-button-color); + font-weight: 500; + cursor: pointer; + + &:hover { + background: var(--psdk-button-hover-bg); + } + + &:active { + background: var(--psdk-button-press-bg); + } + + &:disabled { + background: var(--psdk-button-disabled-bg); + color: var(--psdk-button-disabled-color); + cursor: default; + } +} diff --git a/src/styles/style.scss b/src/styles/style.scss index ca8ace8..d291df9 100644 --- a/src/styles/style.scss +++ b/src/styles/style.scss @@ -27,7 +27,8 @@ psdk-text, psdk-phone, psdk-card-number, psdk-google-pay-button, -psdk-apple-pay { +psdk-apple-pay, +psdk-xsolla-number { @include psdk-typo; .field-error { diff --git a/src/styles/themes/default/variables.scss b/src/styles/themes/default/variables.scss index 7b9583f..1ef7b67 100644 --- a/src/styles/themes/default/variables.scss +++ b/src/styles/themes/default/variables.scss @@ -26,6 +26,7 @@ --psdk-button-press-bg: rgb(110, 123, 247); --psdk-button-disabled-bg: rgb(200, 202, 208); --psdk-button-color: #fff; + --psdk-button-disabled-color: rgba(24, 23, 28, 0.3); --psdk-button-border-radius: var(--psdk-common-border-radius); // payment-methods diff --git a/src/tests/stubs/timeout.ts b/src/tests/stubs/timeout.ts new file mode 100644 index 0000000..9eb9be7 --- /dev/null +++ b/src/tests/stubs/timeout.ts @@ -0,0 +1,2 @@ +export const timeout = async (delay: number): Promise => + new Promise((resolve) => setTimeout(() => resolve(), delay)); diff --git a/src/translations/en.json b/src/translations/en.json index 6b46d57..8d316c9 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -44,7 +44,24 @@ "saving-status.success.title": "Payment method saved", "apple-pay.errors.payment": "Unable to process your payment. Make sure you’re signed in to Apple on your device and set up Apple Pay before you try again. Changes in Apple settings take a couple of minutes to sync.", "apple-pay.errors.device-is-not-capable": "Apple Pay is available only in Safari browser with installed OS 10.12 Sierra or IOS 10. Please change your browser or select another payment option.", - "error.title.default": "Couldn't process payment" + "error.title.default": "Couldn't process payment", + "xsolla-number.payment.description": "To process your payment, we need to verify your account", + "xsolla-number.payment.title": "Payment via {{paymentMethodName}}", + "xsolla-number.info.user-account": "User account", + "xsolla-number.info.xsolla-number": "Xsolla number", + "xsolla-number.instruction.how-to": "How to pay", + "xsolla-number.instruction.paragraph-one": "1. Go to a {{paymentMethodName}} payment location.", + "xsolla-number.instruction.paragraph-two": "2. Press the {{projectName}} or Xsolla button on the payment kiosk.", + "xsolla-number.instruction.paragraph-three": "3. Enter your user account name or Xsolla number.", + "xsolla-number.instruction.paragraph-four": "4. Insert cash to complete the transaction.", + "xsolla-number.instruction.notification": "Terminal owner may charge additional commission", + "xsolla-number.send-panel.get-xsolla-number": "Get Xsolla number", + "xsolla-number.send-panel.send": "Send", + "xsolla-number.send-panel.print": "Print info", + "xsolla-number.send-panel.email": "Email", + "xsolla-number.notification.success.email": "We sent payment instruction to your email. If you haven’t received them, try again", + "xsolla-number.notification.success.sms": "We sent your Xsolla number to your phone number. If you haven't received it, try again", + "xsolla-number.notification.failed": "Can’t send payment instructions. Try again later" } } } diff --git a/src/web-components.ts b/src/web-components.ts index bb326a7..88b0dfd 100644 --- a/src/web-components.ts +++ b/src/web-components.ts @@ -18,6 +18,7 @@ import { ApplePayComponent } from './features/headless-checkout/web-components/a import { PaymentFormComponent } from './features/headless-checkout/web-components/payment-form/payment-form.component'; import { SavedMethodsComponent } from './features/headless-checkout/web-components/saved-methods/saved-methods.component'; import { TotalComponent } from './features/headless-checkout/web-components/finance-details/total.component'; +import { CashPaymentInstructionComponent } from './features/headless-checkout/web-components/cash-payment-instruction/cash-payment-instruction.component'; export { SubmitButtonComponent, @@ -40,4 +41,5 @@ export { PaymentFormComponent, SavedMethodsComponent, TotalComponent, + CashPaymentInstructionComponent, };