diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..d5a1596
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+20.10.0
diff --git a/README.md b/README.md
index e3fa0b2..d0645f8 100644
--- a/README.md
+++ b/README.md
@@ -161,15 +161,15 @@ declare const headlessCheckout: {
### Regular components
| **Component** | **Selector** | **Status** |
-| --------------------- | -------------------- | ---------- |
+| --------------------- |----------------------|-----------|
| Payment Methods | psdk-payment-methods | β
|
-| Saved Methods | β | π |
-| Payment Form Messages | β | π |
+| Saved Methods | psdk-saved-methods | β
|
+| Payment Form Messages | β | π |
| Checkbox | psdk-checkbox | β
|
| Select | psdk-select | β
|
-| Apple Pay Button | β | π |
-| Google Pay Button | β | π |
-| Delete Account Button | β | π |
+| Apple Pay Button | β | π |
+| Google Pay Button | β | π |
+| Delete Account Button | β | π |
| Submit Button | psdk-submit-button | β
|
| User Balance | psdk-user-balance | β
|
| Finance Details | psdk-finance-details | β
|
diff --git a/examples/saved-methods/index.html b/examples/saved-methods/index.html
new file mode 100644
index 0000000..2a83773
--- /dev/null
+++ b/examples/saved-methods/index.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+ Document
+
+
+
+
+
+ Pay Station SDK
+
+
+
+
+
+
+
+
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 daecac0..1bf271d 100644
--- a/src/core/web-components/web-component-tag-name.enum.ts
+++ b/src/core/web-components/web-component-tag-name.enum.ts
@@ -2,6 +2,7 @@ export enum WebComponentTagName {
TextComponent = 'psdk-text',
SubmitButtonComponent = 'psdk-submit-button',
PaymentMethodsComponent = 'psdk-payment-methods',
+ SavedMethodsComponent = 'psdk-saved-methods',
PriceTextComponent = 'psdk-price-text',
FinanceDetailsComponent = 'psdk-finance-details',
LegalComponent = 'psdk-legal',
diff --git a/src/core/web-components/web-components.map.ts b/src/core/web-components/web-components.map.ts
index 9ba8561..9ce631c 100644
--- a/src/core/web-components/web-components.map.ts
+++ b/src/core/web-components/web-components.map.ts
@@ -12,6 +12,7 @@ import { ThreeDsComponent } from '../../features/headless-checkout/web-component
import { PhoneComponent } from '../../features/headless-checkout/web-components/phone-component/phone.component';
import { SelectComponent } from '../../features/headless-checkout/web-components/select/select.component';
import { CheckboxComponent } from '../../features/headless-checkout/web-components/checkbox/checkbox.component';
+import { SavedMethodsComponent } from '../../features/headless-checkout/web-components/saved-methods/saved-methods.component';
import { UserBalanceComponent } from '../../features/headless-checkout/web-components/user-balance-component/user-balance-component';
export const webComponents: {
@@ -20,6 +21,7 @@ export const webComponents: {
[WebComponentTagName.TextComponent]: TextComponent,
[WebComponentTagName.SubmitButtonComponent]: SubmitButtonComponent,
[WebComponentTagName.PaymentMethodsComponent]: PaymentMethodsComponent,
+ [WebComponentTagName.SavedMethodsComponent]: SavedMethodsComponent,
[WebComponentTagName.PriceTextComponent]: PriceTextComponent,
[WebComponentTagName.FinanceDetailsComponent]: FinanceDetailsComponent,
[WebComponentTagName.LegalComponent]: LegalComponent,
diff --git a/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts b/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts
index 6626838..b5b6c72 100644
--- a/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts
+++ b/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts
@@ -50,8 +50,8 @@ export class PaymentMethodsComponent extends WebComponentAbstract {
protected getHtml(): string {
const paymentMethodsHtml = this.getMethodsHtml();
return `
-
-
+
+
${paymentMethodsHtml ? paymentMethodsHtml.join('') : this.notFoundValue}
`;
diff --git a/src/features/headless-checkout/web-components/saved-methods/pipes/get-expire-date.pipe.ts b/src/features/headless-checkout/web-components/saved-methods/pipes/get-expire-date.pipe.ts
new file mode 100644
index 0000000..9be7961
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/pipes/get-expire-date.pipe.ts
@@ -0,0 +1,7 @@
+export function getExpireDate(date?: { month: string; year: string }): string {
+ if (!date) {
+ return '';
+ }
+
+ return `${date.month}/${date.year.slice(2)}`;
+}
diff --git a/src/features/headless-checkout/web-components/saved-methods/pipes/name-cutter.pipe.ts b/src/features/headless-checkout/web-components/saved-methods/pipes/name-cutter.pipe.ts
new file mode 100644
index 0000000..18c5777
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/pipes/name-cutter.pipe.ts
@@ -0,0 +1,41 @@
+import { SavedMethod } from '../../../../../core/saved-method.interface';
+
+const replacerChar = '*';
+
+function findFirstPositionOfNotReplacedChar(value: string): number {
+ const replacedCharLastPosition = value.split('').lastIndexOf(replacerChar);
+ return replacedCharLastPosition > -1
+ ? replacedCharLastPosition + 1
+ : replacedCharLastPosition;
+}
+
+function getTextForCard(cardNumber: string): string {
+ const notReplacedCharFirstPosition =
+ findFirstPositionOfNotReplacedChar(cardNumber);
+ return notReplacedCharFirstPosition === -1
+ ? `${cardNumber}`
+ : `β’β’ ${cardNumber.slice(notReplacedCharFirstPosition).trim()}`;
+}
+
+function getTextForEmail(email: string): string {
+ const firstPositionChar = email.indexOf('*');
+ const notReplacedCharFirstPosition =
+ findFirstPositionOfNotReplacedChar(email);
+ return (
+ email.slice(0, firstPositionChar).trim() +
+ 'β’β’' +
+ email.slice(notReplacedCharFirstPosition).trim()
+ );
+}
+
+export function getCutterName(method: SavedMethod): string {
+ if (method.type === 'card') {
+ return getTextForCard(method.name);
+ }
+
+ if (method.name.includes('@')) {
+ return getTextForEmail(method.name);
+ }
+
+ return method.name.replace(/\*/g, 'β’');
+}
diff --git a/src/features/headless-checkout/web-components/saved-methods/saved-method-attributes.enum.ts b/src/features/headless-checkout/web-components/saved-methods/saved-method-attributes.enum.ts
new file mode 100644
index 0000000..a70bd62
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/saved-method-attributes.enum.ts
@@ -0,0 +1,4 @@
+export enum SavedMethodAttributes {
+ paymentMethodId = 'data-payment-method-id',
+ savedMethodId = 'data-saved-method-id',
+}
diff --git a/src/features/headless-checkout/web-components/saved-methods/saved-method.template.spec.ts b/src/features/headless-checkout/web-components/saved-methods/saved-method.template.spec.ts
new file mode 100644
index 0000000..2e073ff
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/saved-method.template.spec.ts
@@ -0,0 +1,16 @@
+import { getSavedMethodTemplate } from './saved-method.template';
+import { SavedMethod } from '../../../../core/saved-method.interface';
+
+const mockQiwi = {
+ name: '4476 24** **** 9527',
+ psName: 'Mastercard',
+ iconName: 'mastercard.svg',
+ id: 112233,
+} as SavedMethod;
+
+describe('getSavedMethodTemplate', () => {
+ it('Should draw template', () => {
+ expect(getSavedMethodTemplate(mockQiwi)).toContain('img');
+ expect(getSavedMethodTemplate(mockQiwi)).toContain(mockQiwi.name);
+ });
+});
diff --git a/src/features/headless-checkout/web-components/saved-methods/saved-method.template.ts b/src/features/headless-checkout/web-components/saved-methods/saved-method.template.ts
new file mode 100644
index 0000000..4fd3f43
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/saved-method.template.ts
@@ -0,0 +1,31 @@
+import { cdnUrl } from '../../environment';
+import { SavedMethod } from '../../../../core/saved-method.interface';
+import { getExpireDate } from './pipes/get-expire-date.pipe';
+import { getCutterName } from './pipes/name-cutter.pipe';
+
+const iconsPath = `${cdnUrl}/paystation4/brand-logos`;
+
+export const getSavedMethodTemplate = (method: SavedMethod): string => {
+ const expireDate = getExpireDate(method.cardExpiryDate);
+ const name = getCutterName(method);
+ let iconName: string;
+
+ if (!method.iconName) {
+ iconName = 'default.svg';
+ } else {
+ iconName = method.iconName;
+ }
+
+ return `-
+
+
+
+
+ ${method.psName}
+ ${name}
+ ${expireDate ? `${expireDate}` : ''}
+
+
`;
+};
diff --git a/src/features/headless-checkout/web-components/saved-methods/saved-methods-attributes.enum.ts b/src/features/headless-checkout/web-components/saved-methods/saved-methods-attributes.enum.ts
new file mode 100644
index 0000000..3406027
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/saved-methods-attributes.enum.ts
@@ -0,0 +1,3 @@
+export enum SavedMethodsAttributes {
+ notFound = 'not-found',
+}
diff --git a/src/features/headless-checkout/web-components/saved-methods/saved-methods-events.enum.ts b/src/features/headless-checkout/web-components/saved-methods/saved-methods-events.enum.ts
new file mode 100644
index 0000000..f15fed6
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/saved-methods-events.enum.ts
@@ -0,0 +1,3 @@
+export enum SavedMethodsEvents {
+ savedMethodSelected = 'savedMethodSelected',
+}
diff --git a/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.spec.ts b/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.spec.ts
new file mode 100644
index 0000000..cec1e7a
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.spec.ts
@@ -0,0 +1,172 @@
+import { container } from 'tsyringe';
+import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum';
+import { HeadlessCheckoutSpy } from '../../../../core/spy/headless-checkout-spy/headless-checkout-spy';
+import { noopStub } from '../../../../tests/stubs/noop.stub';
+import { HeadlessCheckout } from '../../headless-checkout';
+import { SavedMethodsComponent } from './saved-methods.component';
+import { SavedMethod } from '../../../../core/saved-method.interface';
+import { SavedMethodsEvents } from './saved-methods-events.enum';
+
+function createComponent(): void {
+ const element = document.createElement(
+ WebComponentTagName.SavedMethodsComponent
+ );
+ element.setAttribute('id', 'test');
+ (document.getElementById('container')! as HTMLElement).appendChild(element);
+}
+
+const mockSavedMethod: SavedMethod = {
+ type: 'card',
+ id: 1,
+ currency: 'RUB',
+ name: '2222',
+ pid: 1380,
+ cardExpiryDate: {
+ month: '05',
+ year: '2030',
+ },
+ recurrentType: 'charge',
+ form: {
+ paymentSid: 'id',
+ },
+ replaced: false,
+ psName: 'Mastercard',
+ isSelected: true,
+ iconName: 'mastercard.svg',
+};
+
+describe('SavedMethodsComponent', () => {
+ let headlessCheckout: HeadlessCheckout;
+ let headlessCheckoutSpy: HeadlessCheckoutSpy;
+
+ window.customElements.define(
+ WebComponentTagName.SavedMethodsComponent,
+ SavedMethodsComponent
+ );
+
+ beforeEach(() => {
+ document.body.innerHTML = '';
+
+ headlessCheckout = {
+ getSavedMethods: noopStub,
+ } as unknown as HeadlessCheckout;
+
+ headlessCheckoutSpy = {
+ listenAppInit: noopStub,
+ get appWasInit() {
+ return;
+ },
+ } as unknown as HeadlessCheckoutSpy;
+
+ container.clearInstances();
+
+ container
+ .register(HeadlessCheckoutSpy, {
+ useValue: headlessCheckoutSpy,
+ })
+ .register(HeadlessCheckout, {
+ useValue: headlessCheckout,
+ });
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('Should create component', () => {
+ createComponent();
+ expect(
+ document.querySelector(WebComponentTagName.SavedMethodsComponent)
+ ).toBeDefined();
+ });
+
+ it('Should load saved methods', () => {
+ const spy = spyOn(headlessCheckout, 'getSavedMethods').and.returnValue(
+ Promise.resolve([])
+ );
+ spyOnProperty(headlessCheckoutSpy, 'appWasInit', 'get').and.returnValue(
+ true
+ );
+ createComponent();
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('Should load saved methods after init', () => {
+ const spy = spyOn(headlessCheckout, 'getSavedMethods').and.returnValue(
+ Promise.resolve([])
+ );
+ const appWasInitSpy = spyOnProperty(
+ headlessCheckoutSpy,
+ 'appWasInit',
+ 'get'
+ );
+ const listenAppInitSpy = spyOn(headlessCheckoutSpy, 'listenAppInit');
+ listenAppInitSpy.and.callFake((callback: () => void) => {
+ appWasInitSpy.and.returnValue(true);
+ callback();
+ });
+ appWasInitSpy.and.returnValue(false);
+ createComponent();
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('Should not load saved methods', () => {
+ const spy = spyOn(headlessCheckout, 'getSavedMethods').and.returnValue(
+ Promise.resolve([])
+ );
+ spyOnProperty(headlessCheckoutSpy, 'appWasInit', 'get').and.returnValue(
+ false
+ );
+ createComponent();
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('Should draw 2 payment methods', async () => {
+ spyOn(headlessCheckout, 'getSavedMethods').and.returnValue(
+ Promise.resolve([mockSavedMethod, mockSavedMethod])
+ );
+ spyOnProperty(headlessCheckoutSpy, 'appWasInit', 'get').and.returnValue(
+ true
+ );
+ createComponent();
+
+ // ΠΠ»Ρ ΡΠΎΠ³ΠΎ, ΡΡΠΎΠ±Ρ ΡΡΠΏΠ΅Π»ΠΈ ΠΎΡΡΠΈΡΠΎΠ²Π°ΡΡΡΡ ΠΌΠ΅ΡΠΎΠ΄Ρ
+ await Promise.resolve();
+ expect(document.querySelectorAll('.saved-method')?.length).toBe(2);
+ });
+
+ it('Should draw no saved methods', async () => {
+ spyOn(headlessCheckout, 'getSavedMethods').and.returnValue(
+ Promise.resolve([])
+ );
+ spyOnProperty(headlessCheckoutSpy, 'appWasInit', 'get').and.returnValue(
+ true
+ );
+ createComponent();
+
+ await Promise.resolve();
+ expect(document.querySelectorAll('.saved-method')?.length).toBe(0);
+ });
+
+ it('Should dispatch custom event', async () => {
+ spyOn(headlessCheckout, 'getSavedMethods').and.returnValue(
+ Promise.resolve([mockSavedMethod])
+ );
+ spyOnProperty(headlessCheckoutSpy, 'appWasInit', 'get').and.returnValue(
+ true
+ );
+ createComponent();
+
+ await Promise.resolve();
+ document
+ .querySelector(WebComponentTagName.SavedMethodsComponent)!
+ .addEventListener(SavedMethodsEvents.savedMethodSelected, (event) => {
+ expect((event as CustomEvent).detail).toEqual({
+ paymentMethodId: String(mockSavedMethod.pid),
+ savedMethodId: String(mockSavedMethod.id),
+ });
+ });
+
+ document.querySelector('a')!.click();
+ });
+});
diff --git a/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.ts b/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.ts
new file mode 100644
index 0000000..baaa62a
--- /dev/null
+++ b/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.ts
@@ -0,0 +1,107 @@
+import { WebComponentAbstract } from '../../../../core/web-components/web-component.abstract';
+import { HeadlessCheckout } from '../../headless-checkout';
+import { HeadlessCheckoutSpy } from '../../../../core/spy/headless-checkout-spy/headless-checkout-spy';
+import { container } from 'tsyringe';
+import { SavedMethod } from '../../../../core/saved-method.interface';
+import { getSavedMethodTemplate } from './saved-method.template';
+import { SavedMethodsAttributes } from './saved-methods-attributes.enum';
+import { SavedMethodAttributes } from './saved-method-attributes.enum';
+import { SavedMethodsEvents } from './saved-methods-events.enum';
+
+export class SavedMethodsComponent extends WebComponentAbstract {
+ private readonly headlessCheckout: HeadlessCheckout;
+ private readonly headlessCheckoutSpy: HeadlessCheckoutSpy;
+ private savedMethods?: SavedMethod[];
+
+ private get listRef(): HTMLElement {
+ return this.querySelector('ul') as HTMLElement;
+ }
+
+ private get notFoundValue(): string {
+ return this.getAttribute(SavedMethodsAttributes.notFound) ?? '';
+ }
+
+ public constructor() {
+ super();
+
+ this.headlessCheckoutSpy = container.resolve(HeadlessCheckoutSpy);
+ this.headlessCheckout = container.resolve(HeadlessCheckout);
+ }
+
+ protected connectedCallback(): void {
+ if (!this.headlessCheckoutSpy.appWasInit) {
+ this.headlessCheckoutSpy.listenAppInit(() => this.connectedCallback());
+ return;
+ }
+ this.loadSavedMethods();
+ }
+
+ protected getHtml(): string {
+ const savedMethodsHtml = this.getMethodsHtml();
+ return `
+
+ ${savedMethodsHtml ? savedMethodsHtml.join('') : this.notFoundValue}
+
+ `;
+ }
+
+ private loadSavedMethods(): void {
+ void this.headlessCheckout
+ .getSavedMethods()
+ .then(this.savedMethodsLoadedHandler);
+ }
+
+ private readonly savedMethodsLoadedHandler = (
+ savedMethods: SavedMethod[]
+ ): void => {
+ this.savedMethods = savedMethods;
+
+ super.render();
+ this.listenClicks();
+ };
+
+ private getMethodsHtml(): string[] | null {
+ if (this.savedMethods?.length) {
+ return this.savedMethods.map((method) => getSavedMethodTemplate(method));
+ }
+
+ return null;
+ }
+
+ private listenClicks(): void {
+ this.addEventListenerToElement(this.listRef, 'click', (event) => {
+ event.preventDefault();
+ if (event.target instanceof HTMLElement) {
+ this.dispatchSelectionEvent(event.target);
+ }
+ });
+ }
+
+ private dispatchSelectionEvent(target: HTMLElement): void {
+ const eventOptions = {
+ bubbles: true,
+ composed: true,
+ detail: {
+ ...this.getSavedMethodData(target),
+ },
+ };
+
+ this.listRef.dispatchEvent(
+ new CustomEvent(SavedMethodsEvents.savedMethodSelected, eventOptions)
+ );
+ }
+
+ private getSavedMethodData(target: HTMLElement): {
+ paymentMethodId: string | null | undefined;
+ savedMethodId: string | null | undefined;
+ } {
+ return {
+ paymentMethodId: target
+ .closest('li')
+ ?.getAttribute(SavedMethodAttributes.paymentMethodId),
+ savedMethodId: target
+ .closest('li')
+ ?.getAttribute(SavedMethodAttributes.savedMethodId),
+ };
+ }
+}