/* * 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 {TelegramWebviewEvent} from '../../types'; 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'; 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; 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; 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 = 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 = 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(); } } 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 }); const cvcInputField = new InputFieldCorrected({ labelText: 'CVC', plainText: true, inputMode: 'numeric', autocomplete: 'cc-csc', formatMethod: () => cardFormattingPatterns.cardCvc(cardInputField.value) // validateMethod: (...args) => _5AH3.a.cardCvc(cardInputField.value)(...args) }); 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); cardSection.content.append(...[ cardInputField.container, inputFieldsRow, nameInputField?.container ].filter(Boolean)); 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', noCheckboxSubtitle: true }); 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); } } }