Skip to content

Commit

Permalink
Merge pull request #64 from xsolla/PAYMENTS-18338
Browse files Browse the repository at this point in the history
feat(PAYMENTS-18338): add apple-pay redirect integration
  • Loading branch information
Aleksey-Kornienko-xsolla authored Mar 12, 2024
2 parents dd8fbf9 + 8ba0a35 commit 35f9638
Show file tree
Hide file tree
Showing 30 changed files with 673 additions and 187 deletions.
3 changes: 3 additions & 0 deletions src/core/event-name.enum.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const enum EventName {
isReady = 'isReady',
error = 'error',
initPayment = 'initPayment',
initForm = 'initForm',
Expand Down Expand Up @@ -28,4 +29,6 @@ export const enum EventName {
finishLoadComponent = 'finishLoadComponent',
getFormStatus = 'getFormStatus',
applePayError = 'applePayError',
openApplePayPage = 'openApplePayPage',
submitApplePayForm = 'submitApplePayForm',
}
14 changes: 14 additions & 0 deletions src/core/guards/apple-pay/open-apple-pay-page.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'reflect-metadata';
import { EventName } from '../../event-name.enum';
import { isOpenApplePayPageEventMessage } from './open-apple-pay-page.guard';

describe('Event message type guard', () => {
it('Should return true', () => {
expect(
isOpenApplePayPageEventMessage({ name: EventName.openApplePayPage })
).toBeTruthy();
});
it('Should return false', () => {
expect(isOpenApplePayPageEventMessage({})).toBeFalsy();
});
});
12 changes: 12 additions & 0 deletions src/core/guards/apple-pay/open-apple-pay-page.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Message } from '../../message.interface';
import { isEventMessage } from '../event-message.guard';
import { EventName } from '../../event-name.enum';

export const isOpenApplePayPageEventMessage = (
messageData: unknown
): messageData is Message<{ redirectUrl: string } | null | undefined> => {
if (isEventMessage(messageData)) {
return messageData.name === EventName.openApplePayPage;
}
return false;
};
1 change: 1 addition & 0 deletions src/core/web-components/web-component-tag-name.enum.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum WebComponentTagName {
TextComponent = 'psdk-text',
SubmitButtonComponent = 'psdk-submit-button',
DefaultSubmitButtonComponent = 'psdk-default-submit-button',
PaymentMethodsComponent = 'psdk-payment-methods',
SavedMethodsComponent = 'psdk-saved-methods',
PriceTextComponent = 'psdk-price-text',
Expand Down
3 changes: 3 additions & 0 deletions src/core/web-components/web-components.map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { PaymentFormMessagesComponent } from '../../features/headless-checkout/w
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 { DefaultSubmitButtonComponent } from '../../features/headless-checkout/web-components/submit-button/default-submit-button/default-submit-button.component';

export const webComponents: {
[key in WebComponentTagName]: CustomElementConstructor;
Expand All @@ -41,4 +42,6 @@ export const webComponents: {
[WebComponentTagName.GooglePayButtonComponent]: GooglePayButtonComponent,
[WebComponentTagName.QrCodeComponent]: QrCodeComponent,
[WebComponentTagName.ApplePayComponent]: ApplePayComponent,
[WebComponentTagName.DefaultSubmitButtonComponent]:
DefaultSubmitButtonComponent,
};
5 changes: 3 additions & 2 deletions src/features/headless-checkout/environment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const headlessCheckoutAppUrl =
'https://secure.xsolla.com/headless-checkout';
// export const headlessCheckoutAppUrl =
// 'https://secure.xsolla.com/headless-checkout';
export const headlessCheckoutAppUrl = 'https://localhost:4200';
export const cdnUrl = 'https://cdn3.xsolla.com';
27 changes: 17 additions & 10 deletions src/features/headless-checkout/headless-checkout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { LocalizeService } from '../../core/i18n/localize.service';
import { getFinanceDetailsHandler } from './post-messages-handlers/get-finance-details.handler';
import { FormStatus } from '../../core/status/form-status.enum';
import { noopStub } from '../../tests/stubs/noop.stub';
import { headlessCheckoutAppUrl } from './environment';

const mockMessage: Message = {
name: EventName.initPayment,
Expand All @@ -19,7 +20,6 @@ const mockHandler: Handler<void> = (): null => {
};

class MockIframeElement {
public src = '';
public width = '';
public height = '';
public style = { border: '' };
Expand All @@ -46,6 +46,14 @@ describe('HeadlessCheckout', () => {

beforeEach(() => {
windowService = window;
spyOn(windowService, 'addEventListener').and.callFake(
(name: string, handlerWrapper: EventListenerOrEventListenerObject) => {
(handlerWrapper as (message: MessageEvent) => void)({
origin: headlessCheckoutAppUrl,
data: JSON.stringify({ name: EventName.isReady }),
} as MessageEvent);
}
);
postMessagesClient = {
init: stub,
send: stub,
Expand Down Expand Up @@ -75,17 +83,16 @@ describe('HeadlessCheckout', () => {
const spy = spyOn(postMessagesClient, 'init');
spyOn(windowService.document.body, 'appendChild');
spyOn(windowService.document, 'createElement').and.callFake(
() => new MockIframeElement() as unknown as HTMLIFrameElement,
() => new MockIframeElement() as unknown as HTMLIFrameElement
);

await headlessCheckout.init({ isWebview: false });
expect(spy).toHaveBeenCalled();
});

it('Should init localization', () => {
const localizeSpy = spyOn(
localizeService,
'initDictionaries',
'initDictionaries'
).and.resolveTo();

void headlessCheckout.init({ isWebview: false });
Expand All @@ -104,7 +111,7 @@ describe('HeadlessCheckout', () => {
headlessCheckout.events.onCoreEvent(
EventName.initPayment,
mockHandler,
stub,
stub
);
expect(spy).toHaveBeenCalled();
});
Expand All @@ -113,7 +120,7 @@ describe('HeadlessCheckout', () => {
const spy = spyOn(postMessagesClient, 'listen');
spyOn(windowService.document.body, 'appendChild');
spyOn(windowService.document, 'createElement').and.callFake(
() => new MockIframeElement() as unknown as HTMLIFrameElement,
() => new MockIframeElement() as unknown as HTMLIFrameElement
);

await headlessCheckout.init({ isWebview: false });
Expand Down Expand Up @@ -145,7 +152,7 @@ describe('HeadlessCheckout', () => {
await headlessCheckout.getFinanceDetails();
expect(spy).toHaveBeenCalledWith(
{ name: EventName.financeDetails },
getFinanceDetailsHandler,
getFinanceDetailsHandler
);
});

Expand All @@ -166,7 +173,7 @@ describe('HeadlessCheckout', () => {
spyOn(windowService.customElements, 'get').and.returnValue(undefined);
spyOn(windowService.document.body, 'appendChild');
spyOn(windowService.document, 'createElement').and.callFake(
() => new MockIframeElement() as unknown as HTMLIFrameElement,
() => new MockIframeElement() as unknown as HTMLIFrameElement
);

await headlessCheckout.init({ isWebview: false });
Expand All @@ -175,11 +182,11 @@ describe('HeadlessCheckout', () => {

it('Should web components not be redefined', async () => {
spyOn(windowService.customElements, 'get').and.returnValue(
CustomElementMock,
CustomElementMock
);
spyOn(windowService.document.body, 'appendChild');
spyOn(windowService.document, 'createElement').and.callFake(
() => new MockIframeElement() as unknown as HTMLIFrameElement,
() => new MockIframeElement() as unknown as HTMLIFrameElement
);

const spy = spyOn(window.customElements, 'define');
Expand Down
49 changes: 32 additions & 17 deletions src/features/headless-checkout/headless-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class HeadlessCheckout {
onCoreEvent: <T>(
eventName: EventName,
handler: Handler<T>,
callback: (value?: T) => void,
callback: (value?: T) => void
): (() => void) => {
return this.postMessagesClient.listen(eventName, handler, callback);
},
Expand All @@ -68,6 +68,7 @@ export class HeadlessCheckout {
* @returns {Form} form details
*/
init: async (configuration: FormConfiguration): Promise<Form> => {
this._formConfiguration = configuration;
this.formStatus = FormStatus.pending;

const msg: Message = {
Expand All @@ -84,7 +85,7 @@ export class HeadlessCheckout {
}
this.formSpy.formWasInit = true;
this.formStatus = FormStatus.active;
}),
})
) as Promise<Form>;
},

Expand All @@ -96,12 +97,12 @@ export class HeadlessCheckout {
if (nextAction) {
callbackFn(nextAction);
}
},
}
);
},

onFieldsStatusChange: (
callbackFn: (fieldsStatus: FormFieldsStatus) => void,
callbackFn: (fieldsStatus: FormFieldsStatus) => void
): void => {
this.postMessagesClient.listen<FormFieldsStatus>(
EventName.formFieldsStatusChanged,
Expand All @@ -110,7 +111,7 @@ export class HeadlessCheckout {
if (fieldsStatus) {
callbackFn(fieldsStatus);
}
},
}
);
},

Expand All @@ -128,6 +129,9 @@ export class HeadlessCheckout {
this.formStatus = FormStatus.active;
},
};
public get formConfiguration(): FormConfiguration | undefined {
return this._formConfiguration;
}

private formStatus: FormStatus = FormStatus.undefined;
private isWebView?: boolean;
Expand All @@ -136,13 +140,14 @@ export class HeadlessCheckout {
private coreIframe!: HTMLIFrameElement;
private errorsSubscription?: () => void;
private readonly headlessAppUrl = headlessCheckoutAppUrl;
private _formConfiguration?: FormConfiguration;

public constructor(
private readonly window: Window,
private readonly postMessagesClient: PostMessagesClient,
private readonly localizeService: LocalizeService,
private readonly headlessCheckoutSpy: HeadlessCheckoutSpy,
private readonly formSpy: FormSpy,
private readonly formSpy: FormSpy
) {}

public async init(environment: {
Expand All @@ -156,6 +161,7 @@ export class HeadlessCheckout {

await this.localizeService.initDictionaries();

this.postMessagesClient.init(this.coreIframe, this.headlessAppUrl);
await this.setupCoreIframe();
this.defineComponents();

Expand All @@ -165,7 +171,7 @@ export class HeadlessCheckout {
getErrorHandler,
(error) => {
throw new Error(error);
},
}
);
}

Expand Down Expand Up @@ -194,7 +200,7 @@ export class HeadlessCheckout {
return this.postMessagesClient.send<void>(msg, (message) =>
setTokenHandler(message, () => {
this.headlessCheckoutSpy.appWasInit = true;
}),
})
);
}

Expand All @@ -204,7 +210,7 @@ export class HeadlessCheckout {
name: EventName.setSecureComponentStyles,
data: styles,
},
setSecureComponentStylesHandler,
setSecureComponentStylesHandler
);
}

Expand All @@ -219,7 +225,7 @@ export class HeadlessCheckout {

return this.postMessagesClient.send<FinanceDetails | null>(
msg,
getFinanceDetailsHandler,
getFinanceDetailsHandler
) as Promise<FinanceDetails | null>;
}

Expand All @@ -243,7 +249,7 @@ export class HeadlessCheckout {

return this.postMessagesClient.send<PaymentMethod[]>(
msg,
getRegularMethodsHandler,
getRegularMethodsHandler
) as Promise<PaymentMethod[]>;
}

Expand All @@ -263,7 +269,7 @@ export class HeadlessCheckout {

return this.postMessagesClient.send<PaymentMethod[]>(
msg,
getQuickMethodsHandler,
getQuickMethodsHandler
) as Promise<PaymentMethod[]>;
}

Expand All @@ -274,7 +280,7 @@ export class HeadlessCheckout {

return this.postMessagesClient.send<SavedMethod[]>(
msg,
getSavedMethodsHandler,
getSavedMethodsHandler
) as Promise<SavedMethod[]>;
}

Expand All @@ -285,7 +291,7 @@ export class HeadlessCheckout {

return this.postMessagesClient.send<UserBalance>(
msg,
getUserBalanceHandler,
getUserBalanceHandler
) as Promise<UserBalance>;
}

Expand All @@ -298,7 +304,7 @@ export class HeadlessCheckout {
};

return this.postMessagesClient.send<Status>(msg, (message) =>
getPaymentStatusHandler(message),
getPaymentStatusHandler(message)
) as Promise<Status>;
}

Expand All @@ -311,10 +317,19 @@ export class HeadlessCheckout {
this.coreIframe.src = `${this.headlessAppUrl}/core`;
this.coreIframe.name = 'core';
this.window.document.body.appendChild(this.coreIframe);
return this.listenCoreIframeLoading();
}

private async listenCoreIframeLoading(): Promise<void> {
return new Promise((resolve) => {
this.coreIframe.onload = () => {
resolve();
const handler = (event: MessageEvent): void => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (JSON.parse(event.data as string).name === EventName.isReady) {
resolve();
this.window.removeEventListener('message', handler);
}
};
this.window.addEventListener('message', handler);
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Message } from '../../../../core/message.interface';
import { EventName } from '../../../../core/event-name.enum';
import { openApplePayPageHandler } from './open-apple-pay-page.handler';

const mockMessage: Message<{ redirectUrl: string } | null | undefined> = {
name: EventName.openApplePayPage,
data: {
redirectUrl: 'url',
},
};

describe('openApplePayPageHandler', () => {
it('Should handle data', () => {
expect(openApplePayPageHandler(mockMessage)).toEqual({
isHandled: true,
value: {
redirectUrl: 'url',
},
});
});
it('Should return null', () => {
expect(
openApplePayPageHandler({ name: EventName.getSavedMethods })
).toBeNull();
});
});
Loading

0 comments on commit 35f9638

Please sign in to comment.