tweb/src/components/popups/paymentCard.ts
Eduard Kuzmenko 8770c2a180 some changes
2022-11-11 20:21:29 +04:00

569 lines
18 KiB
TypeScript

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import PopupElement from '.';
import cardFormattingPatterns from '../../helpers/cards/cardFormattingPatterns';
import {detectUnifiedCardBrand} from '../../helpers/cards/cardBrands';
import formatInputValueByPattern from '../../helpers/cards/formatInputValueByPattern';
import {validateAnyIncomplete, validateCardExpiry, validateCardNumber} from '../../helpers/cards/validateCard';
import placeCaretAtEnd from '../../helpers/dom/placeCaretAtEnd';
import {renderImageFromUrlPromise} from '../../helpers/dom/renderImageFromUrl';
import noop from '../../helpers/noop';
import {PaymentsPaymentForm} from '../../layer';
import {LangPackKey, _i18n} from '../../lib/langPack';
import CheckboxField from '../checkboxField';
import confirmationPopup from '../confirmationPopup';
import CountryInputField from '../countryInputField';
import InputField, {InputFieldOptions, InputState} from '../inputField';
import Row from '../row';
import {SettingSection} from '../sidebarLeft';
import {getPaymentBrandIconPath, PaymentButton, PaymentsCredentialsToken} from './payment';
import {createVerificationIframe} from './paymentVerification';
import {PasswordInputHelpers} from '../passwordInputField';
export type PaymentCardDetails = {
cardNumber: string;
cardholderName: string;
expiryFull: string;
expiryMonth: string;
expiryYear: string;
cvc: string;
country: string;
zip: string;
save?: boolean;
};
export type PaymentCardDetailsShort = {
title: string,
save?: boolean;
icon?: string;
};
export type PaymentCardDetailsResult = PaymentCardDetails | PaymentCardDetailsShort;
export class InputFieldCorrected extends InputField {
private lastKeyDown: string;
private lastTransformed: ReturnType<typeof formatInputValueByPattern>;
constructor(public options: InputFieldOptions & {
formatMethod: typeof cardFormattingPatterns['cardNumber'],
validateMethod?: typeof validateCardNumber,
errorKeys?: {[code: string]: LangPackKey},
optional?: boolean,
onChange?: (transformed: InputFieldCorrected['lastTransformed']) => void,
onKeyDown?: (e: KeyboardEvent) => void
}) {
super(options);
// const handleIncomplete = (t?: any) => {
// if(
// (!lastTransformed.value && t) ||
// lastTransformed.meta.autocorrectComplete ||
// lastTransformed.meta.error ||
// optional
// ) {
// return;
// }
// };
this.input.addEventListener('keydown', this.onKeyDown);
this.input.addEventListener('input', this.onInput);
this.input.addEventListener('blur', this.onBlur);
}
private onKeyDown = (e: KeyboardEvent) => {
this.lastKeyDown = e.key;
this.options.onKeyDown?.(e);
};
private onInput = () => {
const value = this.value;
const deleting = this.lastKeyDown === 'Backspace' && (((this.lastTransformed && this.lastTransformed.value.length) || 0) - value.length) === 1;
const result = this.lastTransformed = formatInputValueByPattern({
value: value,
getPattern: this.options.formatMethod,
deleting,
input: this.input
});
const transformedValue = result.value;
if(transformedValue !== value) {
this.setValueSilently(transformedValue);
if(result.selection) {
(this.input as HTMLInputElement).selectionStart = result.selection.selectionStart;
(this.input as HTMLInputElement).selectionEnd = result.selection.selectionEnd;
}
}
this.validateNew(transformedValue, {ignoreIncomplete: true/* !result.meta.autocorrectComplete */});
this.options.onChange?.(result);
};
private onBlur = () => {
const value = this.lastTransformed?.value;
if(value) {
this.validateNew(value);
}
};
public update() {
this.onInput();
}
public validate = () => {
return this.validateNew();
};
public validateNew(
value = this.lastTransformed?.value ?? '',
t: any = {},
justReturn?: boolean
) {
let result: ReturnType<InputFieldCorrected['options']['validateMethod']>;
if(this.options.validateMethod) {
result = this.options.validateMethod?.(value, t);
} else {
result = validateAnyIncomplete(this.lastTransformed, value, t);
}
if(result?.code) {
const langPackKey: LangPackKey = this.options.errorKeys?.[result.code];
!justReturn && this.setState(InputState.Error, langPackKey);
return false;
}
!justReturn && this.setState(InputState.Neutral);
return true;
}
}
export function handleInputFieldsOnChange(inputFields: (CountryInputField | InputField | InputFieldCorrected)[], _onChange: (valid: boolean) => void) {
const onChange = () => {
const valid = inputFields.every((inputField) => {
return 'validateNew' in inputField ? inputField.validateNew(undefined, undefined, true) : inputField.isValid();
});
_onChange(valid);
};
inputFields.forEach((inputField) => {
if(inputField instanceof InputFieldCorrected) {
const original = inputField.options.onChange;
inputField.options.onChange = (...args: any[]) => {
// @ts-ignore
original?.(...args);
onChange();
};
if('update' in inputField) {
inputField.update();
}
} else {
inputField.input.addEventListener('input', onChange);
}
});
return {validate: onChange};
}
export function createCountryZipFields(country?: boolean, zip?: boolean) {
let countryInputField: CountryInputField, postcodeInputField: InputFieldCorrected;
if(country || zip) {
if(country) countryInputField = new CountryInputField({
noPhoneCodes: true,
onCountryChange: () => {
postcodeInputField?.update();
},
required: true,
autocomplete: 'country'
});
if(zip) postcodeInputField = new InputFieldCorrected({
label: 'PaymentShippingZipPlaceholder',
plainText: true,
inputMode: 'numeric',
autocomplete: 'postal-code',
formatMethod: (/* ...args */) => {
const {country} = countryInputField.getSelected();
const iso2 = country?.iso2;
return cardFormattingPatterns.postalCodeFromCountry(iso2 && iso2.toUpperCase());
}
});
}
return {countryInputField, postcodeInputField};
}
export type PaymentsNativeProvider = 'stripe' | 'smartglocal';
export type PaymentsNativeParams = {
need_country?: boolean,
need_zip?: boolean,
need_cardholder_name?: boolean,
publishable_key?: string, // stripe
public_token?: string, // smartglocal
gpay_params: string,
};
const SUPPORTED_NATIVE_PROVIDERS: Set<PaymentsNativeProvider> = new Set(['stripe', 'smartglocal']);
export default class PopupPaymentCard extends PopupElement<{
finish: (obj: {token: any, card: PaymentCardDetailsResult}) => void
}> {
constructor(private paymentForm: PaymentsPaymentForm, private savedCard?: PaymentCardDetails) {
super('popup-payment popup-payment-card', {
closable: true,
overlayClosable: true,
body: true,
scrollable: SUPPORTED_NATIVE_PROVIDERS.has(paymentForm.native_provider as PaymentsNativeProvider),
title: 'PaymentCardInfo'
});
if(SUPPORTED_NATIVE_PROVIDERS.has(paymentForm.native_provider as PaymentsNativeProvider)) {
this.d();
} else {
const {iframe, onMount} = createVerificationIframe(paymentForm.url, (event) => {
if(event.eventType !== 'payment_form_submit') {
return;
}
const data = event.eventData;
const cardOut = {title: data.title, save: false} as any as PaymentCardDetails;
this.dispatchEvent('finish', {
token: data.credentials,
card: cardOut
});
this.hide();
if(paymentForm.pFlags.can_save_credentials) {
confirmationPopup({
titleLangKey: 'PaymentCardSavePaymentInformation',
descriptionLangKey: 'PaymentCardSavePaymentInformationInfoLine1',
button: {
langKey: 'Save'
}
}).then(() => {
cardOut.save = true;
}, noop);
}
});
// putPreloader(this.body, true);
this.body.append(iframe);
this.show();
onMount();
}
}
private d() {
const savedCard = this.savedCard;
const cardSection = new SettingSection({name: 'PaymentInfo.Card.Title', noDelimiter: true, noShadow: true});
const nativeParams: PaymentsNativeParams = JSON.parse(this.paymentForm.native_params.data);
let lastBrand: string, brandIconTempId = 0, lastBrandImg: HTMLImageElement;
const setBrandIcon = (brand: string) => {
if(lastBrand === brand) {
return;
}
const tempId = ++brandIconTempId;
lastBrand = brand;
const path = getPaymentBrandIconPath(brand);
if(!path) {
if(lastBrandImg) {
lastBrandImg.remove();
lastBrandImg = undefined;
}
return;
}
const img = new Image();
img.classList.add('input-field-icon');
renderImageFromUrlPromise(img, path, false).then(() => {
if(brandIconTempId !== tempId) {
return;
}
if(lastBrandImg) {
lastBrandImg.replaceWith(img);
} else {
cardInputField.container.append(img);
}
lastBrandImg = img;
});
};
const cardInputField = new InputFieldCorrected({
label: 'PaymentCardNumber',
plainText: true,
inputMode: 'numeric',
autocomplete: 'cc-number',
formatMethod: cardFormattingPatterns.cardNumber,
validateMethod: validateCardNumber,
errorKeys: {
invalid: 'PaymentCard.Error.Invalid',
incomplete: 'PaymentCard.Error.Incomplete'
},
onChange: (transformed) => {
setBrandIcon(detectUnifiedCardBrand(transformed.value));
cvcInputField.update(); // format cvc
}
});
let nameInputField: InputField;
if(nativeParams.need_cardholder_name) nameInputField = new InputField({
label: 'Checkout.NewCard.CardholderNamePlaceholder',
maxLength: 255,
required: true,
autocomplete: 'cc-name'
});
const expireInputField = new InputFieldCorrected({
label: 'SecureId.Identity.Placeholder.ExpiryDate',
plainText: true,
inputMode: 'numeric',
autocomplete: 'cc-exp',
formatMethod: cardFormattingPatterns.cardExpiry,
validateMethod: validateCardExpiry
});
// handle autocomplete: 01/2345 -> 01/45
expireInputField.input.addEventListener('input', () => {
let value = expireInputField.value;
if(value.length < 5) {
return;
}
const splitted = value.split('/');
if(splitted[1].length !== 4) {
return;
}
value = [splitted[0], splitted[1].slice(2)].join('/');
expireInputField.setValueSilently(value);
}, {capture: true});
const cvcInputField = new InputFieldCorrected({
labelText: 'CVC',
plainText: true,
inputMode: 'numeric',
autocomplete: 'cc-csc',
name: 'cvc',
formatMethod: () => cardFormattingPatterns.cardCvc(cardInputField.value)
// validateMethod: (...args) => _5AH3.a.cardCvc(cardInputField.value)(...args)
});
const passwordHelpers = new PasswordInputHelpers(cvcInputField.container, cvcInputField.input as HTMLInputElement);
const switchFocusOrder: (InputFieldCorrected | InputField)[] = [
cardInputField,
expireInputField,
cvcInputField,
nameInputField
].filter(Boolean);
switchFocusOrder.forEach((inputField) => {
const onKeyDown = (e: KeyboardEvent) => {
if(!inputField.value && e.key === 'Backspace') {
const previousInputField = switchFocusOrder[switchFocusOrder.indexOf(inputField) - 1];
if(previousInputField) {
// previousInputField.value = previousInputField.value.slice(0, -1);
placeCaretAtEnd(previousInputField.input, true);
}
}
};
if(inputField instanceof InputFieldCorrected) {
inputField.options.onKeyDown = onKeyDown;
const original = inputField.options.onChange;
inputField.options.onChange = (transformed) => {
original?.(transformed);
if(document.activeElement === inputField.input && transformed.meta.autocorrectComplete) {
for(let i = switchFocusOrder.indexOf(inputField), length = switchFocusOrder.length; i < length; ++i) {
const nextInputField = switchFocusOrder[i];
if(
nextInputField instanceof InputFieldCorrected ?
!nextInputField.validateNew(undefined, undefined, true) :
!nextInputField.value
) {
placeCaretAtEnd(nextInputField.input, true);
break;
}
}
}
};
} else {
inputField.input.addEventListener('keydown', onKeyDown);
}
});
const inputFieldsRow = document.createElement('div');
inputFieldsRow.classList.add('input-fields-row');
inputFieldsRow.append(expireInputField.container, cvcInputField.container);
const form = document.createElement('form');
form.append(...[
cardInputField.container,
inputFieldsRow,
nameInputField?.container
].filter(Boolean))
cardSection.content.append(form);
let billingSection: SettingSection;
// let saveCheckboxField: CheckboxField;
const {countryInputField, postcodeInputField} = createCountryZipFields(nativeParams.need_country, nativeParams.need_zip);
if(nativeParams.need_country || nativeParams.need_zip) {
billingSection = new SettingSection({name: 'PaymentInfo.Billing.Title', noDelimiter: true, noShadow: true});
// const inputFieldsRow2 = inputFieldsRow.cloneNode() as HTMLElement;
// inputFieldsRow2.append(countryInputField.container, postcodeInputField.container);
// billingSection.content.append(inputFieldsRow2);
billingSection.content.append(...[countryInputField, postcodeInputField].filter(Boolean).map((i) => i.container));
}
const canSave = !!this.paymentForm.pFlags.can_save_credentials;
const saveCheckboxField = new CheckboxField({
text: 'PaymentCardSavePaymentInformation',
checked: !!canSave
});
const saveRow = new Row({
checkboxField: saveCheckboxField,
subtitleLangKey: canSave ? 'PaymentCardSavePaymentInformationInfoLine1' : 'Checkout.2FA.Text'
});
if(!canSave) {
saveRow.container.classList.add('is-disabled');
}
(billingSection || cardSection).content.append(saveRow.container);
this.scrollable.append(...[cardSection, billingSection].filter(Boolean).map((s) => s.container));
const payButton = PaymentButton({
key: 'PaymentInfo.Done',
onClick: async() => {
const data: PaymentCardDetails = {
cardNumber: cardInputField.value,
expiryFull: expireInputField.value,
expiryMonth: expireInputField.value.split('/')[0],
expiryYear: expireInputField.value.split('/')[1],
cvc: cvcInputField.value,
cardholderName: nameInputField?.value,
country: countryInputField?.value,
zip: postcodeInputField?.value,
save: saveCheckboxField?.checked
};
const nativeProvider: PaymentsNativeProvider = this.paymentForm.native_provider as any;
let out: PaymentsCredentialsToken;
if(nativeProvider === 'stripe') {
const url = new URL('https://api.stripe.com/v1/tokens');
url.search = new URLSearchParams({
'card[number]': data.cardNumber,
'card[exp_month]': data.expiryMonth,
'card[exp_year]': data.expiryYear,
'card[cvc]': data.cvc,
'card[address_zip]': data.zip,
'card[address_country]': data.country,
'card[name]': data.cardholderName
}).toString();
const response = await fetch(url.toString(), {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Bearer ${nativeParams.publishable_key}`
}
});
out = await response.json();
} else if(nativeProvider === 'smartglocal') {
const params = {
card: {
number: data.cardNumber.replace(/[^\d]+/g, ''),
expiration_month: data.expiryMonth,
expiration_year: data.expiryYear,
security_code: data.cvc.replace(/[^\d]+/g, '')
}
};
const url = /* DEBUG_PAYMENT_SMART_GLOCAL */false ?
'https://tgb-playground.smart-glocal.com/cds/v1/tokenize/card' :
'https://tgb.smart-glocal.com/cds/v1/tokenize/card';
const response = await fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-PUBLIC-TOKEN': nativeParams.public_token
},
body: JSON.stringify(params)
});
const json: { // smartglocal
data: {
info: {
card_network: string,
card_type: string,
masked_card_number: string
},
token: string
},
status: 'ok'
} = await response.json();
out = {type: 'card', token: json.data.token}
}
this.dispatchEvent('finish', {token: out, card: data});
this.hide();
}
});
const inputFields = ([
cardInputField,
nameInputField,
expireInputField,
cvcInputField,
countryInputField,
postcodeInputField
] as const).filter(Boolean);
handleInputFieldsOnChange(inputFields, (valid) => {
payButton.disabled = !valid;
// payButton.classList.toggle('btn-disabled', !valid);
});
if(savedCard) {
cardInputField.value = savedCard.cardNumber;
expireInputField.value = savedCard.expiryFull;
cvcInputField.value = savedCard.cvc;
nameInputField && (nameInputField.value = savedCard.cardholderName);
countryInputField && (countryInputField.value = savedCard.country);
postcodeInputField && (postcodeInputField.value = savedCard.zip);
}
this.body.append(this.btnConfirmOnEnter = payButton);
this.show();
if(!cardInputField.validateNew(undefined, undefined, true)) {
placeCaretAtEnd(cardInputField.input);
}
}
}