Eduard Kuzmenko
2 years ago
100 changed files with 4042 additions and 834 deletions
@ -0,0 +1,288 @@
@@ -0,0 +1,288 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import IS_EMOJI_SUPPORTED from "../environment/emojiSupport"; |
||||
import cancelEvent from "../helpers/dom/cancelEvent"; |
||||
import simulateEvent from "../helpers/dom/dispatchEvent"; |
||||
import findUpClassName from "../helpers/dom/findUpClassName"; |
||||
import findUpTag from "../helpers/dom/findUpTag"; |
||||
import replaceContent from "../helpers/dom/replaceContent"; |
||||
import setInnerHTML from "../helpers/dom/setInnerHTML"; |
||||
import fastSmoothScroll from "../helpers/fastSmoothScroll"; |
||||
import { randomLong } from "../helpers/random"; |
||||
import { HelpCountry, HelpCountryCode } from "../layer"; |
||||
import I18n, { i18n } from "../lib/langPack"; |
||||
import wrapEmojiText from "../lib/richTextProcessor/wrapEmojiText"; |
||||
import rootScope from "../lib/rootScope"; |
||||
import { getCountryEmoji } from "../vendor/emoji"; |
||||
import InputField, { InputFieldOptions } from "./inputField"; |
||||
import Scrollable from "./scrollable"; |
||||
|
||||
let countries: HelpCountry.helpCountry[]; |
||||
const setCountries = () => { |
||||
countries = I18n.countriesList |
||||
.filter((country) => !country.pFlags?.hidden) |
||||
.sort((a, b) => (a.name || a.default_name).localeCompare(b.name || b.default_name)); |
||||
}; |
||||
|
||||
let init = () => { |
||||
setCountries(); |
||||
rootScope.addEventListener('language_change', () => { |
||||
setCountries(); |
||||
}); |
||||
}; |
||||
|
||||
export default class CountryInputField extends InputField { |
||||
private lastCountrySelected: HelpCountry; |
||||
private lastCountryCodeSelected: HelpCountryCode; |
||||
|
||||
private hideTimeout: number; |
||||
private selectWrapper: HTMLElement; |
||||
|
||||
private liMap: Map<string, HTMLLIElement[]>; |
||||
|
||||
constructor(public options: InputFieldOptions & { |
||||
onCountryChange?: (country: HelpCountry.helpCountry, code: HelpCountryCode.helpCountryCode) => void, |
||||
noPhoneCodes?: boolean |
||||
} = {}) { |
||||
super({ |
||||
label: 'Country', |
||||
name: randomLong(), |
||||
...options, |
||||
}); |
||||
|
||||
if(init) { |
||||
init(); |
||||
init = undefined; |
||||
} |
||||
|
||||
this.liMap = new Map(); |
||||
|
||||
this.container.classList.add('input-select'); |
||||
|
||||
const selectWrapper = this.selectWrapper = document.createElement('div'); |
||||
selectWrapper.classList.add('select-wrapper', 'z-depth-3', 'hide'); |
||||
|
||||
const arrowDown = document.createElement('span'); |
||||
arrowDown.classList.add('arrow', 'arrow-down'); |
||||
this.container.append(arrowDown); |
||||
|
||||
const selectList = document.createElement('ul'); |
||||
selectWrapper.appendChild(selectList); |
||||
|
||||
const scroll = new Scrollable(selectWrapper); |
||||
|
||||
let initSelect = () => { |
||||
initSelect = null; |
||||
|
||||
countries.forEach((c) => { |
||||
const emoji = getCountryEmoji(c.iso2); |
||||
|
||||
const liArr: Array<HTMLLIElement> = []; |
||||
for(let i = 0, length = Math.min(c.country_codes.length, options.noPhoneCodes ? 1 : Infinity); i < length; ++i) { |
||||
const countryCode = c.country_codes[i]; |
||||
const li = document.createElement('li'); |
||||
|
||||
let wrapped = wrapEmojiText(emoji); |
||||
if(IS_EMOJI_SUPPORTED) { |
||||
const spanEmoji = document.createElement('span'); |
||||
setInnerHTML(spanEmoji, wrapped); |
||||
li.append(spanEmoji); |
||||
} else { |
||||
setInnerHTML(li, wrapped); |
||||
} |
||||
|
||||
const el = i18n(c.default_name as any); |
||||
el.dataset.defaultName = c.default_name; |
||||
li.append(el); |
||||
|
||||
if(!options.noPhoneCodes) { |
||||
const span = document.createElement('span'); |
||||
span.classList.add('phone-code'); |
||||
span.innerText = '+' + countryCode.country_code; |
||||
li.appendChild(span); |
||||
} |
||||
|
||||
liArr.push(li); |
||||
selectList.append(li); |
||||
} |
||||
|
||||
this.liMap.set(c.iso2, liArr); |
||||
}); |
||||
|
||||
selectList.addEventListener('mousedown', (e) => { |
||||
if(e.button !== 0) { // other buttons but left shall not pass
|
||||
return; |
||||
} |
||||
|
||||
const target = findUpTag(e.target, 'LI') |
||||
this.selectCountryByTarget(target); |
||||
//console.log('clicked', e, countryName, phoneCode);
|
||||
}); |
||||
|
||||
this.container.appendChild(selectWrapper); |
||||
}; |
||||
|
||||
initSelect(); |
||||
|
||||
this.input.addEventListener('focus', (e) => { |
||||
if(initSelect) { |
||||
initSelect(); |
||||
} else { |
||||
countries.forEach((c) => { |
||||
this.liMap.get(c.iso2).forEach((li) => li.style.display = ''); |
||||
}); |
||||
} |
||||
|
||||
clearTimeout(this.hideTimeout); |
||||
this.hideTimeout = undefined; |
||||
|
||||
selectWrapper.classList.remove('hide'); |
||||
void selectWrapper.offsetWidth; // reflow
|
||||
selectWrapper.classList.add('active'); |
||||
|
||||
this.select(); |
||||
|
||||
fastSmoothScroll({ |
||||
// container: page.pageEl.parentElement.parentElement,
|
||||
container: findUpClassName(this.container, 'scrollable-y'), |
||||
element: this.input, |
||||
position: 'start', |
||||
margin: 4 |
||||
}); |
||||
|
||||
setTimeout(() => { |
||||
if(!mouseDownHandlerAttached) { |
||||
document.addEventListener('mousedown', onMouseDown, {capture: true}); |
||||
mouseDownHandlerAttached = true; |
||||
} |
||||
}, 0); |
||||
}); |
||||
|
||||
let mouseDownHandlerAttached = false; |
||||
const onMouseDown = (e: MouseEvent) => { |
||||
if(findUpClassName(e.target, 'input-select')) { |
||||
return; |
||||
} |
||||
if(e.target === this.input) { |
||||
return; |
||||
} |
||||
|
||||
this.hidePicker(); |
||||
document.removeEventListener('mousedown', onMouseDown, {capture: true}); |
||||
mouseDownHandlerAttached = false; |
||||
}; |
||||
|
||||
/* false && this.input.addEventListener('blur', function(this: typeof this.input, e) { |
||||
hidePicker(); |
||||
|
||||
e.cancelBubble = true; |
||||
}, {capture: true}); */ |
||||
|
||||
const onKeyPress = (e: KeyboardEvent) => { |
||||
const key = e.key; |
||||
if(e.ctrlKey || key === 'Control') return false; |
||||
|
||||
//let i = new RegExp('^' + this.value, 'i');
|
||||
let _value = this.value.toLowerCase(); |
||||
let matches: HelpCountry[] = []; |
||||
countries.forEach((c) => { |
||||
const names = [ |
||||
c.name, |
||||
c.default_name, |
||||
c.iso2 |
||||
]; |
||||
|
||||
names.filter(Boolean).forEach((name) => { |
||||
const abbr = name.split(' ').filter((word) => /\w/.test(word)).map((word) => word[0]).join(''); |
||||
if(abbr.length > 1) { |
||||
names.push(abbr); |
||||
} |
||||
}); |
||||
|
||||
let good = !!names.filter(Boolean).find((str) => str.toLowerCase().indexOf(_value) !== -1)/* === 0 */;//i.test(c.name);
|
||||
|
||||
this.liMap.get(c.iso2).forEach((li) => li.style.display = good ? '' : 'none'); |
||||
if(good) matches.push(c); |
||||
}); |
||||
|
||||
// Код ниже автоматически выберет страну если она осталась одна при поиске
|
||||
/* if(matches.length === 1 && matches[0].li.length === 1) { |
||||
if(matches[0].name === lastCountrySelected) return false; |
||||
//console.log('clicking', matches[0]);
|
||||
|
||||
var clickEvent = document.createEvent('MouseEvents'); |
||||
clickEvent.initEvent('mousedown', true, true); |
||||
matches[0].li[0].dispatchEvent(clickEvent); |
||||
return false; |
||||
} else */if(matches.length === 0) { |
||||
countries.forEach((c) => { |
||||
this.liMap.get(c.iso2).forEach((li) => li.style.display = ''); |
||||
}); |
||||
} else if(matches.length === 1 && key === 'Enter') { |
||||
cancelEvent(e); |
||||
this.selectCountryByTarget(this.liMap.get(matches[0].iso2)[0]); |
||||
} |
||||
}; |
||||
|
||||
this.input.addEventListener('keyup', onKeyPress); |
||||
this.input.addEventListener('keydown', (e) => { |
||||
if(e.key === 'Enter') { |
||||
onKeyPress(e); |
||||
} |
||||
}); |
||||
|
||||
arrowDown.addEventListener('mousedown', (e) => { |
||||
if(this.input.matches(':focus')) { |
||||
this.hidePicker(); |
||||
this.input.blur(); |
||||
} else { |
||||
e.cancelBubble = true; |
||||
e.preventDefault(); |
||||
this.input.focus(); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
public getSelected() { |
||||
return {country: this.lastCountrySelected, code: this.lastCountryCodeSelected}; |
||||
} |
||||
|
||||
public hidePicker = () => { |
||||
if(this.hideTimeout !== undefined) return; |
||||
this.selectWrapper.classList.remove('active'); |
||||
this.hideTimeout = window.setTimeout(() => { |
||||
this.selectWrapper.classList.add('hide'); |
||||
this.hideTimeout = undefined; |
||||
}, 200); |
||||
} |
||||
|
||||
public selectCountryByTarget = (target: HTMLElement) => { |
||||
const defaultName = target.querySelector<HTMLElement>('[data-default-name]').dataset.defaultName; |
||||
const phoneCodeEl = target.querySelector<HTMLElement>('.phone-code'); |
||||
const phoneCode = phoneCodeEl?.innerText; |
||||
const countryCode = phoneCode && phoneCode.replace(/\D/g, ''); |
||||
|
||||
replaceContent(this.input, i18n(defaultName as any)); |
||||
simulateEvent(this.input, 'input'); |
||||
this.lastCountrySelected = countries.find((c) => c.default_name === defaultName); |
||||
this.lastCountryCodeSelected = countryCode && this.lastCountrySelected.country_codes.find((_countryCode) => _countryCode.country_code === countryCode); |
||||
|
||||
this.options.onCountryChange?.(this.lastCountrySelected, this.lastCountryCodeSelected); |
||||
this.hidePicker(); |
||||
} |
||||
|
||||
public selectCountryByIso2(iso2: string) { |
||||
this.selectCountryByTarget(this.liMap.get(iso2)[0]); |
||||
} |
||||
|
||||
public override(country: HelpCountry, code: HelpCountryCode, countryName?: string) { |
||||
replaceContent(this.input, country ? i18n(country.default_name as any) : countryName); |
||||
this.lastCountrySelected = country; |
||||
this.lastCountryCodeSelected = code; |
||||
this.options.onCountryChange?.(this.lastCountrySelected, this.lastCountryCodeSelected); |
||||
} |
||||
} |
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { _i18n } from "../lib/langPack"; |
||||
import InputField, { InputFieldOptions } from "./inputField"; |
||||
import SetTransition from "./singleTransition"; |
||||
|
||||
export default class InputFieldAnimated extends InputField { |
||||
public inputFake: HTMLElement; |
||||
|
||||
//public onLengthChange: (length: number, isOverflow: boolean) => void;
|
||||
// protected wasInputFakeClientHeight: number;
|
||||
// protected showScrollDebounced: () => void;
|
||||
|
||||
constructor(options?: InputFieldOptions) { |
||||
super(options); |
||||
|
||||
this.input.addEventListener('input', () => { |
||||
this.inputFake.innerHTML = this.input.innerHTML; |
||||
this.onFakeInput(); |
||||
}); |
||||
|
||||
if(options.placeholder) { |
||||
_i18n(this.inputFake, options.placeholder, undefined, 'placeholder'); |
||||
} |
||||
|
||||
this.input.classList.add('scrollable', 'scrollable-y'); |
||||
// this.wasInputFakeClientHeight = 0;
|
||||
// this.showScrollDebounced = debounce(() => this.input.classList.remove('no-scrollbar'), 150, false, true);
|
||||
this.inputFake = document.createElement('div'); |
||||
this.inputFake.setAttribute('contenteditable', 'true'); |
||||
this.inputFake.className = this.input.className + ' input-field-input-fake'; |
||||
} |
||||
|
||||
public onFakeInput(setHeight = true) { |
||||
const {scrollHeight: newHeight/* , clientHeight */} = this.inputFake; |
||||
/* if(this.wasInputFakeClientHeight && this.wasInputFakeClientHeight !== clientHeight) { |
||||
this.input.classList.add('no-scrollbar'); // ! в сафари может вообще не появиться скролл после анимации, так как ему нужен полный reflow блока с overflow.
|
||||
this.showScrollDebounced(); |
||||
} */ |
||||
|
||||
const currentHeight = +this.input.style.height.replace('px', ''); |
||||
if(currentHeight === newHeight) { |
||||
return; |
||||
} |
||||
|
||||
const TRANSITION_DURATION_FACTOR = 50; |
||||
const transitionDuration = Math.round( |
||||
TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)), |
||||
); |
||||
|
||||
// this.wasInputFakeClientHeight = clientHeight;
|
||||
this.input.style.transitionDuration = `${transitionDuration}ms`; |
||||
|
||||
if(setHeight) { |
||||
this.input.style.height = newHeight ? newHeight + 'px' : ''; |
||||
} |
||||
|
||||
const className = 'is-changing-height'; |
||||
SetTransition(this.input, className, true, transitionDuration, () => { |
||||
this.input.classList.remove(className); |
||||
}); |
||||
} |
||||
|
||||
public setValueSilently(value: string, fromSet?: boolean) { |
||||
super.setValueSilently(value, fromSet); |
||||
|
||||
this.inputFake.innerHTML = value; |
||||
if(!fromSet) { |
||||
this.onFakeInput(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,787 @@
@@ -0,0 +1,787 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import PopupElement from "."; |
||||
import Currencies from "../../config/currencies"; |
||||
import { FontFamily, FontSize } from "../../config/font"; |
||||
import accumulate from "../../helpers/array/accumulate"; |
||||
import getTextWidth from "../../helpers/canvas/getTextWidth"; |
||||
import { detectUnifiedCardBrand } from "../../helpers/cards/cardBrands"; |
||||
import { attachClickEvent, simulateClickEvent } from "../../helpers/dom/clickEvent"; |
||||
import findUpAsChild from "../../helpers/dom/findUpAsChild"; |
||||
import findUpClassName from "../../helpers/dom/findUpClassName"; |
||||
import loadScript from "../../helpers/dom/loadScript"; |
||||
import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; |
||||
import { renderImageFromUrlPromise } from "../../helpers/dom/renderImageFromUrl"; |
||||
import replaceContent from "../../helpers/dom/replaceContent"; |
||||
import setInnerHTML from "../../helpers/dom/setInnerHTML"; |
||||
import toggleDisability from "../../helpers/dom/toggleDisability"; |
||||
import { formatPhoneNumber } from "../../helpers/formatPhoneNumber"; |
||||
import paymentsWrapCurrencyAmount from "../../helpers/paymentsWrapCurrencyAmount"; |
||||
import ScrollSaver from "../../helpers/scrollSaver"; |
||||
import tsNow from "../../helpers/tsNow"; |
||||
import { AccountTmpPassword, InputPaymentCredentials, LabeledPrice, Message, MessageMedia, PaymentRequestedInfo, PaymentSavedCredentials, PaymentsPaymentForm, PaymentsPaymentReceipt, PaymentsValidatedRequestedInfo, PostAddress, ShippingOption } from "../../layer"; |
||||
import I18n, { i18n, LangPackKey, _i18n } from "../../lib/langPack"; |
||||
import { ApiError } from "../../lib/mtproto/apiManager"; |
||||
import wrapEmojiText from "../../lib/richTextProcessor/wrapEmojiText"; |
||||
import rootScope from "../../lib/rootScope"; |
||||
import AvatarElement from "../avatar"; |
||||
import Button from "../button"; |
||||
import PeerTitle from "../peerTitle"; |
||||
import { putPreloader } from "../putPreloader"; |
||||
import Row from "../row"; |
||||
import { toastNew } from "../toast"; |
||||
import { wrapPhoto } from "../wrappers"; |
||||
import wrapPeerTitle from "../wrappers/peerTitle"; |
||||
import PopupPaymentCard, { PaymentCardDetails, PaymentCardDetailsResult } from "./paymentCard"; |
||||
import PopupPaymentCardConfirmation from "./paymentCardConfirmation"; |
||||
import PopupPaymentShipping, { PaymentShippingAddress } from "./paymentShipping"; |
||||
import PopupPaymentShippingMethods from "./paymentShippingMethods"; |
||||
import PopupPaymentVerification from "./paymentVerification"; |
||||
|
||||
const iconPath = 'assets/img/'; |
||||
const icons = [ |
||||
'amex', |
||||
'card', |
||||
'diners', |
||||
'discover', |
||||
'jcb', |
||||
'mastercard', |
||||
'visa', |
||||
'unionpay', |
||||
'logo', |
||||
]; |
||||
|
||||
export function getPaymentBrandIconPath(brand: string) { |
||||
if(!icons.includes(brand)) { |
||||
return; |
||||
} |
||||
|
||||
return `${iconPath}${brand}.svg`; |
||||
} |
||||
|
||||
export function PaymentButton(options: { |
||||
onClick: () => Promise<any> | void, |
||||
key?: LangPackKey, |
||||
textEl?: I18n.IntlElement |
||||
}) { |
||||
const textEl = options.textEl ?? new I18n.IntlElement({key: options.key ?? 'PaymentInfo.Done'}); |
||||
const key = textEl.key; |
||||
const payButton = Button('btn-primary btn-color-primary payment-item-pay'); |
||||
payButton.append(textEl.element); |
||||
attachClickEvent(payButton, async() => { |
||||
const result = options.onClick(); |
||||
if(!(result instanceof Promise)) { |
||||
return; |
||||
} |
||||
|
||||
const d = putPreloader(payButton); |
||||
const toggle = toggleDisability([payButton], true); |
||||
textEl.compareAndUpdate({key: 'PleaseWait'}); |
||||
try { |
||||
await result; |
||||
} catch(err) { |
||||
if(!(err as any).handled) { |
||||
console.error('payment button error', err); |
||||
} |
||||
|
||||
toggle(); |
||||
textEl.compareAndUpdate({key}); |
||||
d.remove(); |
||||
} |
||||
}); |
||||
return payButton; |
||||
} |
||||
|
||||
export type PaymentsCredentialsToken = {type: 'card', token?: string, id?: string}; |
||||
|
||||
export default class PopupPayment extends PopupElement { |
||||
private currency: string; |
||||
private tipButtonsMap: Map<number, HTMLElement>; |
||||
|
||||
constructor(private message: Message.message) { |
||||
super('popup-payment', { |
||||
closable: true, |
||||
overlayClosable: true, |
||||
body: true, |
||||
scrollable: true, |
||||
title: true |
||||
}); |
||||
|
||||
this.tipButtonsMap = new Map(); |
||||
this.d(); |
||||
} |
||||
|
||||
private async d() { |
||||
this.element.classList.add('is-loading'); |
||||
this.show(); |
||||
|
||||
let confirmed = false; |
||||
const onConfirmed = () => { |
||||
if(confirmed) { |
||||
return; |
||||
} |
||||
|
||||
confirmed = true; |
||||
if(popupPaymentVerification) { |
||||
popupPaymentVerification.hide(); |
||||
} |
||||
|
||||
this.hide(); |
||||
showSuccessToast(); |
||||
}; |
||||
|
||||
const showSuccessToast = () => { |
||||
toastNew({ |
||||
langPackKey: 'PaymentInfoHint', |
||||
langPackArguments: [ |
||||
paymentsWrapCurrencyAmount(getTotalTotal(), currency), |
||||
wrapEmojiText(mediaInvoice.title) |
||||
] |
||||
}); |
||||
}; |
||||
|
||||
this.listenerSetter.add(rootScope)('payment_sent', ({peerId, mid}) => { |
||||
if(this.message.peerId === peerId && this.message.mid === mid) { |
||||
onConfirmed(); |
||||
} |
||||
}); |
||||
|
||||
const {message} = this; |
||||
const mediaInvoice = message.media as MessageMedia.messageMediaInvoice; |
||||
|
||||
_i18n(this.title, mediaInvoice.receipt_msg_id ? 'PaymentReceipt' : 'PaymentCheckout'); |
||||
if(mediaInvoice.pFlags.test) { |
||||
this.title.append(' (Test)'); |
||||
} |
||||
|
||||
const className = 'payment-item'; |
||||
|
||||
const itemEl = document.createElement('div'); |
||||
itemEl.classList.add(className); |
||||
|
||||
const detailsClassName = className + '-details'; |
||||
const details = document.createElement('div'); |
||||
details.classList.add(detailsClassName); |
||||
|
||||
let photoEl: HTMLElement; |
||||
if(mediaInvoice.photo) { |
||||
photoEl = document.createElement('div'); |
||||
photoEl.classList.add(detailsClassName + '-photo', 'media-container-cover'); |
||||
wrapPhoto({ |
||||
photo: mediaInvoice.photo, |
||||
container: photoEl, |
||||
boxWidth: 100, |
||||
boxHeight: 100, |
||||
size: {_: 'photoSizeEmpty', type: ''} |
||||
}); |
||||
details.append(photoEl); |
||||
} |
||||
|
||||
const linesClassName = detailsClassName + '-lines'; |
||||
const lines = document.createElement('div'); |
||||
lines.classList.add(linesClassName); |
||||
|
||||
const title = document.createElement('div'); |
||||
title.classList.add(linesClassName + '-title'); |
||||
|
||||
const description = document.createElement('div'); |
||||
description.classList.add(linesClassName + '-description'); |
||||
|
||||
const botName = document.createElement('div'); |
||||
botName.classList.add(linesClassName + '-bot-name'); |
||||
|
||||
lines.append(title, description, botName); |
||||
|
||||
setInnerHTML(title, wrapEmojiText(mediaInvoice.title)); |
||||
setInnerHTML(description, wrapEmojiText(mediaInvoice.description)); |
||||
|
||||
const peerTitle = new PeerTitle(); |
||||
botName.append(peerTitle.element); |
||||
|
||||
details.append(lines); |
||||
itemEl.append(details); |
||||
this.scrollable.append(itemEl); |
||||
|
||||
const preloaderContainer = document.createElement('div'); |
||||
preloaderContainer.classList.add(className + '-preloader-container'); |
||||
const preloader = putPreloader(preloaderContainer, true); |
||||
this.scrollable.container.append(preloaderContainer); |
||||
|
||||
let paymentForm: PaymentsPaymentForm | PaymentsPaymentReceipt; |
||||
const isReceipt = !!mediaInvoice.receipt_msg_id; |
||||
|
||||
if(isReceipt) paymentForm = await this.managers.appPaymentsManager.getPaymentReceipt(message.peerId, mediaInvoice.receipt_msg_id); |
||||
else paymentForm = await this.managers.appPaymentsManager.getPaymentForm(message.peerId, message.mid); |
||||
|
||||
let savedInfo = (paymentForm as PaymentsPaymentForm).saved_info || (paymentForm as PaymentsPaymentReceipt).info; |
||||
const savedCredentials = (paymentForm as PaymentsPaymentForm).saved_credentials; |
||||
let [lastRequestedInfo, passwordState, providerPeerTitle] = await Promise.all([ |
||||
!isReceipt && savedInfo && this.managers.appPaymentsManager.validateRequestedInfo(message.peerId, message.mid, savedInfo), |
||||
savedCredentials && this.managers.passwordManager.getState(), |
||||
wrapPeerTitle({peerId: paymentForm.provider_id.toPeerId()}) |
||||
]); |
||||
|
||||
console.log(paymentForm, lastRequestedInfo); |
||||
|
||||
await peerTitle.update({peerId: paymentForm.bot_id.toPeerId()}); |
||||
preloaderContainer.remove(); |
||||
this.element.classList.remove('is-loading'); |
||||
|
||||
const wrapAmount = (amount: string | number, skipSymbol?: boolean) => { |
||||
return paymentsWrapCurrencyAmount(amount, currency, skipSymbol); |
||||
}; |
||||
|
||||
const {invoice} = paymentForm; |
||||
const currency = this.currency = invoice.currency; |
||||
|
||||
const makeLabel = () => { |
||||
const labelEl = document.createElement('div'); |
||||
labelEl.classList.add(pricesClassName + '-price'); |
||||
|
||||
const left = document.createElement('span'); |
||||
const right = document.createElement('span'); |
||||
labelEl.append(left, right); |
||||
return {label: labelEl, left, right}; |
||||
}; |
||||
|
||||
const pricesClassName = className + '-prices'; |
||||
const prices = document.createElement('div'); |
||||
prices.classList.add(pricesClassName); |
||||
const makePricesElements = (prices: LabeledPrice[]) => { |
||||
return prices.map((price) => { |
||||
const {amount, label} = price; |
||||
|
||||
const _label = makeLabel(); |
||||
_label.left.textContent = label; |
||||
|
||||
const wrappedAmount = wrapAmount(Math.abs(+amount)); |
||||
_label.right.textContent = (amount < 0 ? '-' : '') + wrappedAmount; |
||||
|
||||
return _label.label; |
||||
}); |
||||
}; |
||||
|
||||
const pricesElements = makePricesElements(invoice.prices); |
||||
|
||||
let getTipsAmount = (): number => 0; |
||||
let shippingAmount = 0; |
||||
|
||||
const getTotalTotal = () => totalAmount + getTipsAmount() + shippingAmount; |
||||
const setTotal = () => { |
||||
const wrapped = wrapAmount(getTotalTotal()); |
||||
totalLabel.right.textContent = wrapped; |
||||
payI18n.compareAndUpdate({ |
||||
key: 'PaymentCheckoutPay', |
||||
args: [wrapped] |
||||
}); |
||||
}; |
||||
|
||||
const payI18n = new I18n.IntlElement(); |
||||
|
||||
const totalLabel = makeLabel(); |
||||
totalLabel.label.classList.add('is-total'); |
||||
_i18n(totalLabel.left, 'PaymentTransactionTotal'); |
||||
const totalAmount = accumulate(invoice.prices.map(({amount}) => +amount), 0); |
||||
|
||||
const canTip = invoice.max_tip_amount !== undefined; |
||||
if(canTip) { |
||||
const tipsClassName = className + '-tips'; |
||||
|
||||
const currencyData = Currencies[currency]; |
||||
|
||||
getTipsAmount = () => +getInputValue().replace(/\D/g, ''); |
||||
|
||||
const getInputValue = () => { |
||||
// return input.textContent;
|
||||
return input.value; |
||||
}; |
||||
|
||||
const setInputWidth = () => { |
||||
const width = getTextWidth(getInputValue(), `500 ${FontSize} ${FontFamily}`); |
||||
input.style.width = width + 'px'; |
||||
}; |
||||
|
||||
const setInputValue = (amount: string | number) => { |
||||
amount = Math.min(+amount, +invoice.max_tip_amount); |
||||
const wrapped = wrapAmount(amount, true); |
||||
|
||||
input.value = wrapped; |
||||
// input.textContent = wrapped;
|
||||
if(document.activeElement === input) { |
||||
placeCaretAtEnd(input); |
||||
} |
||||
|
||||
unsetActiveTip(); |
||||
const tipEl = this.tipButtonsMap.get(amount); |
||||
if(tipEl) { |
||||
tipEl.classList.add('active'); |
||||
} |
||||
|
||||
setInputWidth(); |
||||
setTotal(); |
||||
}; |
||||
|
||||
const tipsLabel = makeLabel(); |
||||
_i18n(tipsLabel.left, mediaInvoice.receipt_msg_id ? 'PaymentTip' : 'PaymentTipOptional'); |
||||
const input = document.createElement('input'); |
||||
input.type = 'tel'; |
||||
// const input: HTMLElement = document.createElement('div');
|
||||
// input.contentEditable = 'true';
|
||||
input.classList.add('input-clear', tipsClassName + '-input'); |
||||
tipsLabel.right.append(input); |
||||
|
||||
tipsLabel.label.style.cursor = 'text'; |
||||
tipsLabel.label.addEventListener('mousedown', (e) => { |
||||
if(!findUpAsChild(e.target, input)) { |
||||
placeCaretAtEnd(input); |
||||
} |
||||
}); |
||||
|
||||
const haveToIgnoreEvents = input instanceof HTMLInputElement ? 1 : 2; |
||||
const onSelectionChange = () => { |
||||
if(ignoreNextSelectionChange) { |
||||
--ignoreNextSelectionChange; |
||||
return; |
||||
} |
||||
|
||||
// setTimeout(() => {
|
||||
ignoreNextSelectionChange = haveToIgnoreEvents; |
||||
placeCaretAtEnd(input); |
||||
// }, 0);
|
||||
}; |
||||
|
||||
const onFocus = () => { |
||||
|
||||
// cancelEvent(e);
|
||||
setTimeout(() => { |
||||
ignoreNextSelectionChange = haveToIgnoreEvents; |
||||
placeCaretAtEnd(input); |
||||
document.addEventListener('selectionchange', onSelectionChange); |
||||
}, 0); |
||||
}; |
||||
|
||||
const onFocusOut = () => { |
||||
input.addEventListener('focus', onFocus, {once: true}); |
||||
document.removeEventListener('selectionchange', onSelectionChange); |
||||
}; |
||||
|
||||
let ignoreNextSelectionChange: number; |
||||
input.addEventListener('focusout', onFocusOut); |
||||
onFocusOut(); |
||||
|
||||
input.addEventListener('input', () => { |
||||
setInputValue(getTipsAmount()); |
||||
}); |
||||
|
||||
let s = [currencyData.symbol, currencyData.space_between ? ' ' : '']; |
||||
if(!currencyData.symbol_left) s.reverse(); |
||||
tipsLabel.right[currencyData.symbol_left ? 'prepend' : 'append'](s.join('')); |
||||
|
||||
pricesElements.push(tipsLabel.label); |
||||
|
||||
///
|
||||
const tipsEl = document.createElement('div'); |
||||
tipsEl.classList.add(tipsClassName); |
||||
|
||||
const tipClassName = tipsClassName + '-tip'; |
||||
const tipButtons = invoice.suggested_tip_amounts.map((tipAmount) => { |
||||
const button = Button(tipClassName, {noRipple: true}); |
||||
button.textContent = wrapAmount(tipAmount); |
||||
|
||||
this.tipButtonsMap.set(+tipAmount, button); |
||||
return button; |
||||
}); |
||||
|
||||
const unsetActiveTip = () => { |
||||
const prevTipEl = tipsEl.querySelector('.active'); |
||||
if(prevTipEl) { |
||||
prevTipEl.classList.remove('active'); |
||||
} |
||||
}; |
||||
|
||||
attachClickEvent(tipsEl, (e) => { |
||||
const tipEl = findUpClassName(e.target, tipClassName); |
||||
if(!tipEl) { |
||||
return; |
||||
} |
||||
|
||||
let tipAmount = 0; |
||||
if(tipEl.classList.contains('active')) { |
||||
tipEl.classList.remove('active'); |
||||
} else { |
||||
unsetActiveTip(); |
||||
tipEl.classList.add('active'); |
||||
|
||||
for(const [amount, el] of this.tipButtonsMap) { |
||||
if(el === tipEl) { |
||||
tipAmount = amount; |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
setInputValue(tipAmount); |
||||
}); |
||||
|
||||
setInputValue(0); |
||||
|
||||
tipsEl.append(...tipButtons); |
||||
pricesElements.push(tipsEl); |
||||
} else { |
||||
setTotal(); |
||||
} |
||||
|
||||
pricesElements.push(totalLabel.label); |
||||
|
||||
prices.append(...pricesElements); |
||||
itemEl.append(prices); |
||||
|
||||
///
|
||||
|
||||
const setRowIcon = async(row: Row, icon?: string) => { |
||||
const img = document.createElement('img'); |
||||
img.classList.add('media-photo'); |
||||
await renderImageFromUrlPromise(img, getPaymentBrandIconPath(icon)); |
||||
let container = row.media; |
||||
if(!container) { |
||||
container = row.createMedia('small'); |
||||
container.classList.add('media-container-cover'); |
||||
container.append(img); |
||||
} else { |
||||
replaceContent(container, img); |
||||
} |
||||
}; |
||||
|
||||
const createRow = (options: ConstructorParameters<typeof Row>[0]) => { |
||||
if(options.titleLangKey) { |
||||
options.subtitleLangKey = options.titleLangKey; |
||||
} |
||||
|
||||
const row = new Row(options); |
||||
row.container.classList.add(className + '-row'); |
||||
|
||||
if(options.titleLangKey) { |
||||
row.subtitle.classList.add('hide'); |
||||
} |
||||
|
||||
return row; |
||||
}; |
||||
|
||||
const setRowTitle = (row: Row, textContent: string) => { |
||||
row.title.textContent = textContent; |
||||
if(!textContent) { |
||||
const e = I18n.weakMap.get(row.subtitle) as I18n.IntlElement; |
||||
row.title.append(i18n(e.key)); |
||||
} |
||||
|
||||
row.subtitle.classList.toggle('hide', !textContent); |
||||
}; |
||||
|
||||
const setCardSubtitle = (card: PaymentCardDetailsResult) => { |
||||
let brand: string; |
||||
let str: string; |
||||
let icon: string; |
||||
if('title' in card) { |
||||
brand = card.title.split(' ').shift(); |
||||
str = card.title; |
||||
icon = card.icon; |
||||
} else { |
||||
brand = detectUnifiedCardBrand(card.cardNumber); |
||||
str = brand + ' *' + card.cardNumber.split(' ').pop(); |
||||
} |
||||
|
||||
methodRow.title.classList.remove('tgico', 'tgico-card_outline'); |
||||
setRowIcon(methodRow, icon || brand.toLowerCase()); |
||||
setRowTitle(methodRow, str); |
||||
}; |
||||
|
||||
const onMethodClick = () => { |
||||
new PopupPaymentCard(paymentForm as PaymentsPaymentForm, previousCardDetails as PaymentCardDetails).addEventListener('finish', ({token, card}) => { |
||||
previousToken = token, previousCardDetails = card; |
||||
|
||||
setCardSubtitle(card); |
||||
}); |
||||
}; |
||||
|
||||
let previousCardDetails: PaymentCardDetailsResult, previousToken: PaymentsCredentialsToken; |
||||
const methodRow = createRow({ |
||||
titleLangKey: 'PaymentCheckoutMethod', |
||||
clickable: isReceipt ? undefined : onMethodClick, |
||||
icon: 'card_outline' |
||||
}); |
||||
|
||||
methodRow.container.classList.add(className + '-method-row'); |
||||
|
||||
if(savedCredentials) { |
||||
setCardSubtitle(savedCredentials); |
||||
} else if((paymentForm as PaymentsPaymentReceipt).credentials_title) { |
||||
setCardSubtitle({title: (paymentForm as PaymentsPaymentReceipt).credentials_title}); |
||||
} |
||||
|
||||
const providerRow = createRow({ |
||||
title: providerPeerTitle, |
||||
subtitleLangKey: 'PaymentCheckoutProvider' |
||||
}); |
||||
|
||||
const providerAvatar = new AvatarElement(); |
||||
providerAvatar.classList.add('avatar-32'); |
||||
providerRow.createMedia('small').append(providerAvatar); |
||||
/* await */ providerAvatar.updateWithOptions({peerId: paymentForm.provider_id.toPeerId()}); |
||||
|
||||
let shippingAddressRow: Row, shippingNameRow: Row, shippingEmailRow: Row, shippingPhoneRow: Row, shippingMethodRow: Row; |
||||
let lastShippingOption: ShippingOption, onShippingAddressClick: (focus?: ConstructorParameters<typeof PopupPaymentShipping>[2]) => void, onShippingMethodClick: () => void; |
||||
const setShippingTitle = invoice.pFlags.shipping_address_requested ? (shippingAddress?: PaymentShippingAddress) => { |
||||
if(!shippingAddress) { |
||||
shippingMethodRow.subtitle.classList.add('hide'); |
||||
replaceContent(shippingMethodRow.title, i18n('PaymentShippingAddress')); |
||||
return; |
||||
} |
||||
|
||||
const postAddress = shippingAddress.shipping_address; |
||||
setRowTitle(shippingAddressRow, [postAddress.city, postAddress.street_line1, postAddress.street_line2].filter(Boolean).join(', ')); |
||||
shippingMethodRow.container.classList.remove('hide'); |
||||
} : undefined; |
||||
|
||||
const setShippingInfo = (info: PaymentRequestedInfo) => { |
||||
setShippingTitle && setShippingTitle(info); |
||||
shippingNameRow && setRowTitle(shippingNameRow, info.name); |
||||
shippingEmailRow && setRowTitle(shippingEmailRow, info.email); |
||||
shippingPhoneRow && setRowTitle(shippingPhoneRow, info.phone && ('+' + formatPhoneNumber(info.phone).formatted)); |
||||
}; |
||||
|
||||
if(!isReceipt) { |
||||
onShippingAddressClick = (focus) => { |
||||
new PopupPaymentShipping(paymentForm as PaymentsPaymentForm, message, focus).addEventListener('finish', ({shippingAddress, requestedInfo}) => { |
||||
lastRequestedInfo = requestedInfo; |
||||
savedInfo = (paymentForm as PaymentsPaymentForm).saved_info = shippingAddress; |
||||
setShippingInfo(shippingAddress); |
||||
}); |
||||
}; |
||||
} |
||||
|
||||
if(invoice.pFlags.shipping_address_requested) { |
||||
const setShippingOption = (shippingOption?: ShippingOption) => { |
||||
const scrollSaver = new ScrollSaver(this.scrollable, undefined, true); |
||||
scrollSaver.save(); |
||||
if(lastShippingPricesElements) { |
||||
lastShippingPricesElements.forEach((node) => node.remove()); |
||||
} |
||||
|
||||
if(!shippingOption) { |
||||
shippingAmount = 0; |
||||
|
||||
setTotal(); |
||||
scrollSaver.restore(); |
||||
this.onContentUpdate(); |
||||
return; |
||||
} |
||||
|
||||
lastShippingOption = shippingOption; |
||||
setRowTitle(shippingMethodRow, shippingOption.title); |
||||
|
||||
shippingAmount = accumulate(shippingOption.prices.map(({amount}) => +amount), 0); |
||||
lastShippingPricesElements = makePricesElements(shippingOption.prices); |
||||
let l = totalLabel.label; |
||||
if(canTip) l = l.previousElementSibling.previousElementSibling as any; |
||||
lastShippingPricesElements.forEach((element) => l.parentElement.insertBefore(element, l)); |
||||
|
||||
setTotal(); |
||||
scrollSaver.restore(); |
||||
this.onContentUpdate(); |
||||
}; |
||||
|
||||
shippingAddressRow = createRow({ |
||||
icon: 'location', |
||||
titleLangKey: 'PaymentShippingAddress', |
||||
clickable: !isReceipt && onShippingAddressClick.bind(null, undefined) |
||||
}); |
||||
|
||||
let lastShippingPricesElements: HTMLElement[]; |
||||
shippingMethodRow = createRow({ |
||||
icon: 'car', |
||||
titleLangKey: 'PaymentCheckoutShippingMethod', |
||||
clickable: !isReceipt && (onShippingMethodClick = () => { |
||||
new PopupPaymentShippingMethods(paymentForm as PaymentsPaymentForm, lastRequestedInfo, lastShippingOption).addEventListener('finish', (shippingOption) => { |
||||
setShippingOption(shippingOption); |
||||
}); |
||||
}) |
||||
}); |
||||
|
||||
shippingMethodRow.container.classList.add('hide'); |
||||
|
||||
const shippingOption = (paymentForm as PaymentsPaymentReceipt).shipping; |
||||
if(shippingOption) { |
||||
setShippingOption(shippingOption); |
||||
} |
||||
} |
||||
|
||||
if(invoice.pFlags.name_requested) { |
||||
shippingNameRow = createRow({ |
||||
icon: 'newprivate', |
||||
titleLangKey: 'PaymentCheckoutName', |
||||
clickable: !isReceipt && onShippingAddressClick.bind(null, 'name') |
||||
}); |
||||
} |
||||
|
||||
if(invoice.pFlags.email_requested) { |
||||
shippingEmailRow = createRow({ |
||||
icon: 'mention', |
||||
titleLangKey: 'PaymentShippingEmailPlaceholder', |
||||
clickable: !isReceipt && onShippingAddressClick.bind(null, 'email') |
||||
}); |
||||
} |
||||
|
||||
if(invoice.pFlags.phone_requested) { |
||||
shippingPhoneRow = createRow({ |
||||
icon: 'phone', |
||||
titleLangKey: 'PaymentCheckoutPhoneNumber', |
||||
clickable: !isReceipt && onShippingAddressClick.bind(null, 'phone') |
||||
}); |
||||
} |
||||
|
||||
if(savedInfo) { |
||||
setShippingInfo(savedInfo); |
||||
} |
||||
|
||||
const rows = [ |
||||
methodRow, |
||||
providerRow, |
||||
shippingAddressRow, |
||||
shippingMethodRow, |
||||
shippingNameRow, |
||||
shippingEmailRow, |
||||
shippingPhoneRow, |
||||
].filter(Boolean); |
||||
this.scrollable.append(...[ |
||||
document.createElement('hr'), |
||||
...rows.map((row) => row.container) |
||||
].filter(Boolean)); |
||||
|
||||
///
|
||||
let popupPaymentVerification: PopupPaymentVerification, lastTmpPasword: AccountTmpPassword; |
||||
const onClick = () => { |
||||
const missingInfo = invoice.pFlags.name_requested && !savedInfo?.name ? 'name' : (invoice.pFlags.email_requested && !savedInfo?.email ? 'email' : (invoice.pFlags.phone_requested && !savedInfo?.phone ? 'phone' : undefined)); |
||||
if(invoice.pFlags.shipping_address_requested) { |
||||
if(!lastRequestedInfo) { |
||||
onShippingAddressClick(); |
||||
return; |
||||
} else if(!lastShippingOption) { |
||||
onShippingMethodClick(); |
||||
return; |
||||
} |
||||
} else if(missingInfo) { |
||||
onShippingAddressClick(missingInfo); |
||||
return; |
||||
} |
||||
|
||||
if(!previousCardDetails && !lastTmpPasword) { |
||||
if(!savedCredentials) { |
||||
onMethodClick(); |
||||
return; |
||||
} |
||||
|
||||
Promise.resolve(passwordState ?? this.managers.passwordManager.getState()).then((_passwordState) => { |
||||
new PopupPaymentCardConfirmation(savedCredentials.title, _passwordState).addEventListener('finish', (tmpPassword) => { |
||||
passwordState = undefined; |
||||
lastTmpPasword = tmpPassword; |
||||
simulateClickEvent(payButton); |
||||
|
||||
// * reserve 5 seconds
|
||||
const diff = tmpPassword.valid_until - tsNow(true) - 5; |
||||
setTimeout(() => { |
||||
if(lastTmpPasword === tmpPassword) { |
||||
lastTmpPasword = undefined; |
||||
} |
||||
}, diff * 1000); |
||||
}); |
||||
}); |
||||
|
||||
return; |
||||
} |
||||
|
||||
return Promise.resolve().then(async() => { |
||||
const credentials: InputPaymentCredentials = lastTmpPasword ? { |
||||
_: 'inputPaymentCredentialsSaved', |
||||
id: savedCredentials.id, |
||||
tmp_password: lastTmpPasword.tmp_password |
||||
} : { |
||||
_: 'inputPaymentCredentials', |
||||
data: { |
||||
_: 'dataJSON', |
||||
data: JSON.stringify(previousToken.token ? previousToken : {type: previousToken.type, id: previousToken.id}) |
||||
}, |
||||
pFlags: { |
||||
save: previousCardDetails.save || undefined |
||||
} |
||||
}; |
||||
|
||||
try { |
||||
const paymentResult = await this.managers.appPaymentsManager.sendPaymentForm( |
||||
message.peerId, |
||||
message.mid, |
||||
(paymentForm as PaymentsPaymentForm).form_id, |
||||
lastRequestedInfo?.id, |
||||
lastShippingOption?.id, |
||||
credentials, |
||||
getTipsAmount() |
||||
); |
||||
|
||||
if(paymentResult._ === 'payments.paymentResult') { |
||||
onConfirmed(); |
||||
} else { |
||||
popupPaymentVerification = new PopupPaymentVerification(paymentResult.url); |
||||
await new Promise<void>((resolve, reject) => { |
||||
popupPaymentVerification.addEventListener('close', () => { |
||||
if(confirmed) { |
||||
resolve(); |
||||
} else { |
||||
const err = new Error('payment not finished'); |
||||
(err as ApiError).handled = true; |
||||
reject(err); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
popupPaymentVerification.addEventListener('finish', () => { |
||||
onConfirmed(); |
||||
}); |
||||
} |
||||
|
||||
this.hide(); |
||||
} catch(err) { |
||||
if((err as ApiError).type === 'BOT_PRECHECKOUT_TIMEOUT') { |
||||
toastNew({langPackKey: 'Error.AnError'}); |
||||
(err as ApiError).handled = true; |
||||
} else if((err as ApiError).type === 'TMP_PASSWORD_INVALID') { |
||||
passwordState = lastTmpPasword = undefined; |
||||
simulateClickEvent(payButton); |
||||
(err as ApiError).handled = true; |
||||
} |
||||
|
||||
throw err; |
||||
} |
||||
}); |
||||
}; |
||||
|
||||
let payButton: HTMLElement; |
||||
if(isReceipt) { |
||||
payButton = PaymentButton({ |
||||
onClick: () => this.hide(), |
||||
key: 'Done' |
||||
}); |
||||
} else { |
||||
payButton = PaymentButton({ |
||||
onClick: onClick, |
||||
textEl: payI18n |
||||
}); |
||||
} |
||||
|
||||
this.body.append(this.btnConfirmOnEnter = payButton); |
||||
|
||||
this.onContentUpdate(); |
||||
} |
||||
} |
@ -0,0 +1,545 @@
@@ -0,0 +1,545 @@
|
||||
/* |
||||
* 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<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 = 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); |
||||
} |
||||
}); |
||||
|
||||
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); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
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); |
||||
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; |
||||
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); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import PopupElement from "."; |
||||
import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; |
||||
import { AccountPassword, AccountTmpPassword } from "../../layer"; |
||||
import { ApiError } from "../../lib/mtproto/apiManager"; |
||||
import { InputState } from "../inputField"; |
||||
import PasswordInputField from "../passwordInputField"; |
||||
import { SettingSection } from "../sidebarLeft"; |
||||
import { PaymentButton } from "./payment"; |
||||
|
||||
export default class PopupPaymentCardConfirmation extends PopupElement<{ |
||||
finish: (tmpPassword: AccountTmpPassword) => void |
||||
}> { |
||||
constructor(card: string, passwordState: AccountPassword) { |
||||
super('popup-payment popup-payment-card-confirmation', { |
||||
closable: true, |
||||
overlayClosable: true, |
||||
body: true, |
||||
scrollable: true, |
||||
title: 'Checkout.PasswordEntry.Title' |
||||
}); |
||||
|
||||
const section = new SettingSection({noDelimiter: true, noShadow: true, caption: 'Checkout.PasswordEntry.Text', captionArgs: [card]}); |
||||
const passwordInputField = new PasswordInputField({labelText: passwordState.hint}); |
||||
section.content.append(passwordInputField.container); |
||||
this.scrollable.append(section.container); |
||||
|
||||
const onInput = () => { |
||||
payButton.disabled = !passwordInputField.value; |
||||
passwordInputField.setState(InputState.Neutral); |
||||
}; |
||||
|
||||
passwordInputField.input.addEventListener('input', onInput); |
||||
|
||||
const payButton = PaymentButton({ |
||||
key: 'Checkout.PasswordEntry.Pay', |
||||
onClick: async() => { |
||||
try { |
||||
const inputCheckPassword = await this.managers.passwordManager.getInputCheckPassword(passwordInputField.value, passwordState); |
||||
const tmpPassword = await this.managers.apiManager.invokeApi('account.getTmpPassword', { |
||||
password: inputCheckPassword, |
||||
period: 60 |
||||
}); |
||||
|
||||
this.dispatchEvent('finish', tmpPassword); |
||||
this.hide(); |
||||
} catch(err) { |
||||
if((err as ApiError).type === 'PASSWORD_HASH_INVALID') { |
||||
(err as ApiError).handled = true; |
||||
passwordInputField.setError('PASSWORD_HASH_INVALID'); |
||||
} |
||||
|
||||
throw err; |
||||
} |
||||
} |
||||
}); |
||||
this.body.append(this.btnConfirmOnEnter = payButton); |
||||
|
||||
onInput(); |
||||
|
||||
this.show(); |
||||
|
||||
placeCaretAtEnd(passwordInputField.input); |
||||
} |
||||
} |
@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import PopupElement from "."; |
||||
import { attachClickEvent } from "../../helpers/dom/clickEvent"; |
||||
import placeCaretAtEnd from "../../helpers/dom/placeCaretAtEnd"; |
||||
import toggleDisability from "../../helpers/dom/toggleDisability"; |
||||
import { Message, PaymentRequestedInfo, PaymentsPaymentForm, PaymentsValidatedRequestedInfo } from "../../layer"; |
||||
import getServerMessageId from "../../lib/appManagers/utils/messageId/getServerMessageId"; |
||||
import { ApiError } from "../../lib/mtproto/apiManager"; |
||||
import matchEmail from "../../lib/richTextProcessor/matchEmail"; |
||||
import Button from "../button"; |
||||
import CheckboxField from "../checkboxField"; |
||||
import CountryInputField from "../countryInputField"; |
||||
import InputField from "../inputField"; |
||||
import Row from "../row"; |
||||
import { SettingSection } from "../sidebarLeft"; |
||||
import TelInputField from "../telInputField"; |
||||
import { PaymentButton } from "./payment"; |
||||
import { createCountryZipFields, handleInputFieldsOnChange, InputFieldCorrected } from "./paymentCard"; |
||||
|
||||
export type PaymentShippingAddress = PaymentRequestedInfo; |
||||
|
||||
type ShippingFocusField = 'name' | 'email' | 'phone'; |
||||
|
||||
export default class PopupPaymentShipping extends PopupElement<{ |
||||
finish: (o: {shippingAddress: PaymentShippingAddress, requestedInfo: PaymentsValidatedRequestedInfo}) => void |
||||
}> { |
||||
constructor( |
||||
private paymentForm: PaymentsPaymentForm, |
||||
private message: Message.message, |
||||
private focus?: ShippingFocusField |
||||
) { |
||||
super('popup-payment popup-payment-shipping', { |
||||
closable: true, |
||||
overlayClosable: true, |
||||
body: true, |
||||
scrollable: true, |
||||
title: 'PaymentShippingInfo' |
||||
}); |
||||
|
||||
this.d(); |
||||
} |
||||
|
||||
private d() { |
||||
const paymentForm = this.paymentForm; |
||||
const invoice = paymentForm.invoice; |
||||
const savedInfo = this.paymentForm.saved_info; |
||||
|
||||
let addressSection: SettingSection, |
||||
address1InputField: InputField, |
||||
address2InputField: InputField, |
||||
cityInputField: InputField, |
||||
stateInputField: InputField, |
||||
countryInputField: CountryInputField, |
||||
postcodeInputField: InputFieldCorrected; |
||||
if(invoice.pFlags.shipping_address_requested) { |
||||
addressSection = new SettingSection({name: 'PaymentShippingAddress', noDelimiter: true, noShadow: true}); |
||||
address1InputField = new InputField({label: 'PaymentShippingAddress1Placeholder', maxLength: 64, required: true}); |
||||
address2InputField = new InputField({label: 'PaymentShippingAddress2Placeholder', maxLength: 64}); |
||||
cityInputField = new InputField({label: 'PaymentShippingCityPlaceholder', maxLength: 64, required: true}); |
||||
stateInputField = new InputField({label: 'PaymentShippingStatePlaceholder', maxLength: 64}); |
||||
const res = createCountryZipFields(true, true); |
||||
countryInputField = res.countryInputField; |
||||
postcodeInputField = res.postcodeInputField; |
||||
|
||||
addressSection.content.append(...[ |
||||
address1InputField, |
||||
address2InputField, |
||||
cityInputField, |
||||
stateInputField, |
||||
countryInputField, |
||||
postcodeInputField |
||||
].filter(Boolean).map((inputField) => inputField.container)); |
||||
} |
||||
|
||||
let receiverSection: SettingSection; |
||||
let nameInputField: InputField, emailInputField: InputField, telInputField: TelInputField; |
||||
if([invoice.pFlags.name_requested, invoice.pFlags.email_requested, invoice.pFlags.phone_requested].includes(true)) { |
||||
receiverSection = new SettingSection({name: 'PaymentShippingReceiver', noDelimiter: true, noShadow: true}); |
||||
|
||||
const validateEmail = () => { |
||||
const value = emailInputField.value; |
||||
const match = matchEmail(value); |
||||
if(!match || match[0].length !== value.length) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
}; |
||||
|
||||
const validatePhone = () => { |
||||
return !!telInputField.value.match(/\d/); |
||||
}; |
||||
|
||||
if(invoice.pFlags.name_requested) nameInputField = new InputField({label: 'PaymentShippingName', maxLength: 256, required: true}); |
||||
if(invoice.pFlags.email_requested) emailInputField = new InputField({label: 'PaymentShippingEmailPlaceholder', maxLength: 64, required: true, validate: validateEmail}); |
||||
if(invoice.pFlags.phone_requested) telInputField = new TelInputField({required: true, validate: validatePhone}); |
||||
|
||||
receiverSection.content.append(...[ |
||||
nameInputField, |
||||
emailInputField, |
||||
telInputField, |
||||
].filter(Boolean).map((inputField) => inputField.container)); |
||||
} |
||||
|
||||
const saveCheckboxField = new CheckboxField({ |
||||
text: 'PaymentShippingSave', |
||||
checked: true |
||||
}); |
||||
const saveRow = new Row({ |
||||
checkboxField: saveCheckboxField, |
||||
subtitleLangKey: 'PaymentShippingSaveInfo', |
||||
noCheckboxSubtitle: true |
||||
}); |
||||
|
||||
(receiverSection || addressSection).content.append(saveRow.container); |
||||
|
||||
this.scrollable.append(...[addressSection, receiverSection].filter(Boolean).map((section) => section.container)); |
||||
|
||||
const payButton = PaymentButton({ |
||||
key: 'PaymentInfo.Done', |
||||
onClick: async() => { |
||||
const selectedCountry = countryInputField && countryInputField.getSelected().country; |
||||
const data: PaymentShippingAddress = { |
||||
_: 'paymentRequestedInfo', |
||||
shipping_address: selectedCountry && { |
||||
_: 'postAddress', |
||||
street_line1: address1InputField.value, |
||||
street_line2: address2InputField.value, |
||||
city: cityInputField.value, |
||||
state: stateInputField.value, |
||||
// country: countryInputField.value,
|
||||
country_iso2: selectedCountry?.iso2, |
||||
post_code: postcodeInputField.value, |
||||
}, |
||||
name: nameInputField?.value, |
||||
email: emailInputField?.value, |
||||
phone: telInputField?.value |
||||
}; |
||||
|
||||
try { |
||||
const requestedInfo = await this.managers.appPaymentsManager.validateRequestedInfo(this.message.peerId, this.message.mid, data, saveCheckboxField?.checked); |
||||
|
||||
this.dispatchEvent('finish', { |
||||
shippingAddress: data, |
||||
requestedInfo |
||||
}); |
||||
|
||||
this.hide(); |
||||
} catch(err: any) { |
||||
const errorMap: {[err: string]: InputField} = { |
||||
ADDRESS_STREET_LINE1_INVALID: address1InputField, |
||||
ADDRESS_STREET_LINE2_INVALID: address2InputField, |
||||
ADDRESS_COUNTRY_INVALID: countryInputField, |
||||
ADDRESS_CITY_INVALID: cityInputField, |
||||
ADDRESS_STATE_INVALID: stateInputField, |
||||
ADDRESS_POSTCODE_INVALID: postcodeInputField, |
||||
|
||||
REQ_INFO_NAME_INVALID: nameInputField, |
||||
REQ_INFO_EMAIL_INVALID: emailInputField, |
||||
REQ_INFO_PHONE_INVALID: telInputField |
||||
}; |
||||
|
||||
const inputField = errorMap[(err as ApiError).type]; |
||||
if(inputField) { |
||||
inputField.setError(); |
||||
(err as any).handled = true; |
||||
} |
||||
|
||||
throw err; |
||||
} |
||||
} |
||||
}); |
||||
this.body.append(this.btnConfirmOnEnter = payButton); |
||||
|
||||
if(savedInfo) { |
||||
const shippingAddress = savedInfo.shipping_address; |
||||
if(shippingAddress) { |
||||
address1InputField.value = shippingAddress.street_line1; |
||||
address2InputField.value = shippingAddress.street_line2; |
||||
cityInputField.value = shippingAddress.city; |
||||
stateInputField.value = shippingAddress.state; |
||||
countryInputField.selectCountryByIso2(shippingAddress.country_iso2); |
||||
postcodeInputField.value = shippingAddress.post_code; |
||||
} |
||||
|
||||
savedInfo.name && nameInputField && (nameInputField.value = savedInfo.name); |
||||
savedInfo.email && emailInputField && (emailInputField.value = savedInfo.email); |
||||
savedInfo.phone && telInputField && (telInputField.value = savedInfo.phone); |
||||
} |
||||
|
||||
const {validate} = handleInputFieldsOnChange([ |
||||
address1InputField, |
||||
address2InputField, |
||||
cityInputField, |
||||
stateInputField, |
||||
countryInputField, |
||||
postcodeInputField, |
||||
nameInputField, |
||||
emailInputField, |
||||
telInputField |
||||
].filter(Boolean), (valid) => { |
||||
payButton.disabled = !valid; |
||||
}); |
||||
|
||||
validate(); |
||||
|
||||
this.show(); |
||||
|
||||
let focusField: InputField; |
||||
if(this.focus) { |
||||
const focusMap: {[field in ShippingFocusField]?: InputField} = { |
||||
name: nameInputField, |
||||
email: emailInputField, |
||||
phone: telInputField |
||||
}; |
||||
|
||||
focusField = focusMap[this.focus]; |
||||
} else { |
||||
focusField = address1InputField; |
||||
} |
||||
|
||||
if(focusField) { |
||||
placeCaretAtEnd(focusField.input); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import PopupElement from "."; |
||||
import accumulate from "../../helpers/array/accumulate"; |
||||
import { attachClickEvent } from "../../helpers/dom/clickEvent"; |
||||
import paymentsWrapCurrencyAmount from "../../helpers/paymentsWrapCurrencyAmount"; |
||||
import { PaymentsPaymentForm, PaymentsValidatedRequestedInfo, ShippingOption } from "../../layer"; |
||||
import Button from "../button"; |
||||
import RadioField from "../radioField"; |
||||
import Row, { RadioFormFromRows } from "../row"; |
||||
import { SettingSection } from "../sidebarLeft"; |
||||
import { PaymentButton } from "./payment"; |
||||
|
||||
export default class PopupPaymentShippingMethods extends PopupElement<{ |
||||
finish: (shippingOption: ShippingOption) => void |
||||
}> { |
||||
constructor( |
||||
private paymentForm: PaymentsPaymentForm, |
||||
private requestedInfo: PaymentsValidatedRequestedInfo, |
||||
private shippingOption: ShippingOption |
||||
) { |
||||
super('popup-payment popup-payment-shipping-methods', { |
||||
closable: true, |
||||
overlayClosable: true, |
||||
body: true, |
||||
scrollable: true, |
||||
title: 'PaymentShippingMethod' |
||||
}); |
||||
|
||||
this.d(); |
||||
} |
||||
|
||||
private d() { |
||||
const section = new SettingSection({name: 'PaymentCheckoutShippingMethod', noDelimiter: true, noShadow: true}); |
||||
|
||||
const rows = this.requestedInfo.shipping_options.map((shippingOption) => { |
||||
return new Row({ |
||||
radioField: new RadioField({ |
||||
text: shippingOption.title, |
||||
name: 'shipping-method', |
||||
value: shippingOption.id |
||||
}), |
||||
subtitle: paymentsWrapCurrencyAmount( |
||||
accumulate(shippingOption.prices.map(({amount}) => +amount), 0), |
||||
this.paymentForm.invoice.currency |
||||
) |
||||
}); |
||||
}); |
||||
|
||||
let lastShippingId: string; |
||||
const form = RadioFormFromRows(rows, (value) => { |
||||
lastShippingId = value; |
||||
}); |
||||
|
||||
if(this.shippingOption) { |
||||
rows.find((row) => row.radioField.input.value === this.shippingOption.id).radioField.checked = true; |
||||
} else { |
||||
rows[0].radioField.checked = true; |
||||
} |
||||
|
||||
section.content.append(form); |
||||
|
||||
this.scrollable.append(section.container); |
||||
|
||||
const payButton = PaymentButton({ |
||||
key: 'PaymentInfo.Done', |
||||
onClick: () => { |
||||
this.dispatchEvent('finish', this.requestedInfo.shipping_options.find((option) => option.id === lastShippingId)); |
||||
this.hide(); |
||||
} |
||||
}); |
||||
this.body.append(this.btnConfirmOnEnter = payButton); |
||||
|
||||
this.show(); |
||||
} |
||||
} |
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import PopupElement from "."; |
||||
import appImManager from "../../lib/appManagers/appImManager"; |
||||
import { TelegramWebviewEventCallback } from "../../types"; |
||||
|
||||
const weakMap: WeakMap<Window, TelegramWebviewEventCallback> = new WeakMap(); |
||||
window.addEventListener('message', (e) => { |
||||
const callback = weakMap.get(e.source as Window); |
||||
if(!callback) { |
||||
return; |
||||
} |
||||
|
||||
callback(JSON.parse(e.data)); |
||||
}); |
||||
|
||||
export function createVerificationIframe(url: string, callback: TelegramWebviewEventCallback) { |
||||
const iframe = document.createElement('iframe'); |
||||
// iframe.title = 'Complete Payment';
|
||||
iframe.allow = 'payment'; |
||||
iframe.setAttribute('sandbox', 'allow-forms allow-scripts allow-same-origin allow-top-navigation allow-modals'); |
||||
iframe.classList.add('payment-verification'); |
||||
iframe.src = url; |
||||
|
||||
iframe.addEventListener('load', () => { |
||||
weakMap.set(iframe.contentWindow, callback); |
||||
}, {once: true}); |
||||
|
||||
return iframe; |
||||
} |
||||
|
||||
export default class PopupPaymentVerification extends PopupElement<{ |
||||
finish: () => void |
||||
}> { |
||||
constructor(private url: string) { |
||||
super('popup-payment popup-payment-verification', { |
||||
closable: true, |
||||
overlayClosable: true, |
||||
body: true, |
||||
title: 'Checkout.WebConfirmation.Title' |
||||
}); |
||||
|
||||
this.d(); |
||||
} |
||||
|
||||
private d() { |
||||
const iframe = createVerificationIframe(this.url, (event) => { |
||||
if(event.eventType !== 'web_app_open_tg_link') { |
||||
return; |
||||
} |
||||
|
||||
this.dispatchEvent('finish'); |
||||
this.hide(); |
||||
appImManager.openUrl('https://t.me' + event.eventData.path_full); |
||||
}); |
||||
|
||||
this.body.append(iframe); |
||||
this.show(); |
||||
} |
||||
} |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
export const FontFamily = 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif'; |
||||
export const FontSize = '16px'; |
||||
export const FontWeight = '400'; |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
export default function createArray<T1>(length: number, fill?: T1, map?: any): T1[] { |
||||
const arr = new Array<T1>(length); |
||||
arr.fill(fill); |
||||
return map ? arr.map(map) : arr; |
||||
} |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
function cacheCallback<A, T>(callback: (str: A) => T) { |
||||
const stringResults: any = {}, numberResults: any = {}; |
||||
return (value: A): T => { |
||||
const key = '_' + value; |
||||
return (typeof(value) === 'string' ? stringResults : numberResults)[key] ??= callback(value); |
||||
}; |
||||
} |
||||
|
||||
export default cacheCallback; |
@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
|
||||
import cacheCallback from "../cacheCallback"; |
||||
import replaceNonNumber from "../string/replaceNonNumber"; |
||||
|
||||
const CARD_BRAND_REGEXP: {[brand: string]: RegExp} = { |
||||
visa: /^4/, |
||||
mastercard: /^(51|52|53|54|55|22|23|24|25|26|27)/, |
||||
amex: /^(34|37)/, |
||||
discover: /^(60|64|65)/, |
||||
diners: /^(30|38|39)/, |
||||
diners14: /^(36)/, |
||||
jcb: /^(35)/, |
||||
unionpay: /^(62[0-6,8-9]|627[0-6,8-9]|6277[0-7,9]|62778[1-9]|81)/, |
||||
elo: /^(5067|509|636368|627780)/ |
||||
}; |
||||
|
||||
// * taken from Stripe
|
||||
export const CARD_BRANDS: {[b: string]: { |
||||
minLength: number, |
||||
maxLength: number, |
||||
cvcMaxLength: number, |
||||
cvcMinLength: number | null |
||||
}} = { |
||||
visa: { |
||||
minLength: 16, |
||||
maxLength: 16, |
||||
cvcMaxLength: 3, |
||||
cvcMinLength: null |
||||
}, |
||||
mastercard: { |
||||
minLength: 16, |
||||
maxLength: 16, |
||||
cvcMaxLength: 3, |
||||
cvcMinLength: null |
||||
}, |
||||
amex: { |
||||
minLength: 15, |
||||
maxLength: 15, |
||||
cvcMaxLength: 4, |
||||
cvcMinLength: 3 |
||||
}, |
||||
unionpay: { |
||||
minLength: 13, |
||||
maxLength: 19, |
||||
cvcMaxLength: 3, |
||||
cvcMinLength: null |
||||
}, |
||||
diners: { |
||||
minLength: 16, |
||||
maxLength: 16, |
||||
cvcMaxLength: 3, |
||||
cvcMinLength: null |
||||
}, |
||||
diners14: { |
||||
minLength: 14, |
||||
maxLength: 14, |
||||
cvcMaxLength: 3, |
||||
cvcMinLength: null |
||||
}, |
||||
discover: { |
||||
minLength: 16, |
||||
maxLength: 16, |
||||
cvcMaxLength: 3, |
||||
cvcMinLength: null |
||||
}, |
||||
jcb: { |
||||
minLength: 16, |
||||
maxLength: 16, |
||||
cvcMaxLength: 3, |
||||
cvcMinLength: null |
||||
}, |
||||
elo: { |
||||
minLength: 16, |
||||
maxLength: 16, |
||||
cvcMaxLength: 3, |
||||
cvcMinLength: null |
||||
}, |
||||
unknown: { |
||||
minLength: 16, |
||||
maxLength: 16, |
||||
cvcMaxLength: 4, |
||||
cvcMinLength: 3 |
||||
} |
||||
}; |
||||
|
||||
export const detectCardBrand = cacheCallback((card: string = '') => { |
||||
const keys = Object.keys(CARD_BRAND_REGEXP); |
||||
const sanitizedCard = replaceNonNumber(card); |
||||
let brand: string; |
||||
let last = 0; |
||||
keys.forEach((key) => { |
||||
const regExp = CARD_BRAND_REGEXP[key]; |
||||
const match = sanitizedCard.match(regExp); |
||||
if(match) { |
||||
const result = match[0]; |
||||
if(result && result.length > last) { |
||||
brand = key; |
||||
last = result.length; |
||||
} |
||||
} |
||||
}); |
||||
|
||||
return brand || 'unknown'; |
||||
}); |
||||
|
||||
export function cardBrandToUnifiedBrand(brand: string) { |
||||
return brand === 'diners14' ? 'diners' : brand; |
||||
} |
||||
|
||||
export function detectUnifiedCardBrand(card = '') { |
||||
const brand = detectCardBrand(card); |
||||
return cardBrandToUnifiedBrand(brand); |
||||
} |
@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
|
||||
import { IS_ANDROID } from "../../environment/userAgent"; |
||||
import createArray from "../array/createArray"; |
||||
import cacheCallback from "../cacheCallback"; |
||||
import replaceNonNumber from "../string/replaceNonNumber"; |
||||
import { CARD_BRANDS, detectCardBrand } from "./cardBrands"; |
||||
import patternCharacters from "./patternCharacters"; |
||||
|
||||
const digit = patternCharacters.digit; |
||||
const capitalCharacter = patternCharacters.capitalCharacter; |
||||
const spaceCharacter = patternCharacters.formattingCharacter(' '); |
||||
const yearOptionalPattern = patternCharacters.optionalPattern(/\d\d/); |
||||
const sixteenPattern = [digit, digit, digit, digit, spaceCharacter, digit, digit, digit, digit, digit, digit, spaceCharacter, digit, digit, digit, digit, digit]; |
||||
const fifteenPattern = [digit, digit, digit, digit, spaceCharacter, digit, digit, digit, digit, digit, digit, spaceCharacter, digit, digit, digit, digit]; |
||||
|
||||
const requiredPostcodes = new Set(['DZ', 'AR', 'AM', 'AU', 'AT', 'AZ', 'PT', 'BD', 'BY', 'BE', 'BA', 'BR', 'BN', 'BG', 'CA', 'IC', 'CN', 'CO', 'HR', 'CY', 'CZ', 'DK', 'EC', 'GB', 'EE', 'FO', 'FI', 'FR', 'GE', 'DE', 'GR', 'GL', 'GU', 'GG', 'NL', 'HU', 'IN', 'ID', 'IL', 'IT', 'JP', 'JE', 'KZ', 'KR', 'FM', 'KG', 'LV', 'LI', 'LT', 'LU', 'MK', 'MG', 'PT', 'MY', 'MH', 'MQ', 'YT', 'MX', 'MN', 'ME', 'NL', 'NZ', 'GB', 'NO', 'PK', 'PH', 'PL', 'FM', 'PT', 'PR', 'RE', 'RU', 'SA', 'SF', 'RS', 'SG', 'SK', 'SI', 'ZA', 'ES', 'LK', 'SX', 'VI', 'VI', 'SE', 'CH', 'TW', 'TJ', 'TH', 'TU', 'TN', 'TR', 'TM', 'VI', 'UA', 'GB', 'US', 'UY', 'UZ', 'VA', 'VN', 'GB', 'FM']); |
||||
|
||||
const generateFourPattern = cacheCallback((length: number) => { |
||||
const out: Array<typeof digit | typeof spaceCharacter> = []; |
||||
|
||||
for(let i = 0, k = 0; i < length;) { |
||||
if(k === 4) { |
||||
out.push(spaceCharacter); |
||||
k = 0; |
||||
} else { |
||||
out.push(digit); |
||||
++i; |
||||
++k; |
||||
} |
||||
} |
||||
|
||||
return out; |
||||
}); |
||||
|
||||
function generateCardNumberPattern(card: string) { |
||||
const brand = detectCardBrand(card); |
||||
if(brand === 'amex') return sixteenPattern; |
||||
if(brand === 'diners14') return fifteenPattern; |
||||
const {minLength, maxLength} = CARD_BRANDS[brand]; |
||||
const s = replaceNonNumber(card).length; |
||||
const d = Math.min(Math.max(minLength, s), maxLength); |
||||
return generateFourPattern(d); |
||||
} |
||||
|
||||
const cardFormattingPatterns = { |
||||
cardNumber: generateCardNumberPattern, |
||||
cardExpiry: () => [patternCharacters.month, patternCharacters.formattingCharacter('/'), digit, digit, yearOptionalPattern], |
||||
cardCvc: (card?: string) => cardFormattingPatterns.cardCvcFromBrand(detectCardBrand(card)), |
||||
cardCvcFromBrand: cacheCallback((brand: string) => { |
||||
const info = CARD_BRANDS[brand]; |
||||
const {cvcMinLength, cvcMaxLength} = info; |
||||
const pattern = createArray(cvcMinLength || cvcMaxLength, digit); |
||||
if(cvcMinLength && cvcMinLength < cvcMaxLength) { |
||||
const i = cvcMaxLength - cvcMinLength; |
||||
const h = patternCharacters.optionalPattern(/\d/); |
||||
if(i) { |
||||
pattern.push(...createArray(i, h)); |
||||
} |
||||
} |
||||
|
||||
return pattern; |
||||
}), |
||||
postalCodeFromCountry: cacheCallback((iso2: string) => { |
||||
switch(iso2) { |
||||
case 'US': |
||||
return createArray(5, digit); |
||||
case 'CA': |
||||
return IS_ANDROID ? null : [capitalCharacter, capitalCharacter, capitalCharacter, spaceCharacter, capitalCharacter, capitalCharacter, capitalCharacter]; |
||||
default: |
||||
const optionalDigits = createArray(10, patternCharacters.optionalPattern(/\d/)); |
||||
if(requiredPostcodes.has(iso2)) { |
||||
optionalDigits[0] = digit; |
||||
} |
||||
return optionalDigits; |
||||
} |
||||
}) |
||||
}; |
||||
|
||||
export default cardFormattingPatterns; |
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
import formatValueByPattern from "./formatValueByPattern"; |
||||
|
||||
export default function formatInputValueByPattern(options: { |
||||
value: string, |
||||
getPattern: Parameters<typeof formatValueByPattern>[0], |
||||
deleting?: boolean, |
||||
input?: HTMLElement |
||||
}) { |
||||
const {value: originalValue, getPattern, deleting, input} = options; |
||||
const pushRest = !deleting && !!originalValue.length; |
||||
const result = formatValueByPattern(getPattern, originalValue, { |
||||
selectionStart: input ? (input as HTMLInputElement).selectionStart : 0, |
||||
selectionEnd: input ? (input as HTMLInputElement).selectionEnd : 0 |
||||
}, pushRest) |
||||
const {value, selection} = result; |
||||
|
||||
return { |
||||
value, |
||||
meta: { |
||||
autocorrectComplete: result.autocorrectComplete, |
||||
empty: !value |
||||
}, |
||||
selection |
||||
}; |
||||
} |
@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
|
||||
import accumulate from "../array/accumulate"; |
||||
import { PatternFunction } from "./patternCharacters"; |
||||
|
||||
function accumulateLengths(strs: string[]) { |
||||
return accumulate(strs.map((str) => str.length), 0); |
||||
} |
||||
|
||||
function formatValueByPattern( |
||||
getPattern: PatternFunction, |
||||
value: string, |
||||
options: Partial<{ |
||||
selectionStart: number, |
||||
selectionEnd: number |
||||
}> = {}, |
||||
pushRest?: boolean |
||||
) { |
||||
const pattern = getPattern(value); |
||||
|
||||
if(!pattern) { |
||||
return { |
||||
value: value, |
||||
selection: null as typeof options, |
||||
autocorrectComplete: !!value |
||||
}; |
||||
} |
||||
|
||||
const length = pattern.length; |
||||
const c: string[] = []; |
||||
const s: string[] = []; |
||||
|
||||
let l = 0; |
||||
let i = 0; |
||||
let f = options.selectionStart === 0 ? 0 : null; |
||||
let d = options.selectionEnd === 0 ? 0 : null; |
||||
const p = () => { |
||||
if(f === null && (i + 1) >= options.selectionStart) f = accumulateLengths(c) + (pushRest ? s.length : 0); |
||||
if(d === null && (i + 1) >= options.selectionEnd) d = accumulateLengths(c) + (pushRest ? s.length : 0); |
||||
}; |
||||
const m = (e: number) => { |
||||
if(e > 0) { |
||||
p(); |
||||
i += e; |
||||
} |
||||
}; |
||||
|
||||
for(; l < length;) { |
||||
const getCharacter = pattern[l]; |
||||
const character = getCharacter(value.slice(i)); |
||||
const {type, result, consumed} = character; |
||||
if(type === 'required') { |
||||
if(result) { |
||||
c.push(...s, result); |
||||
s.length = 0; |
||||
++l; |
||||
|
||||
if(character.partial) { |
||||
m(value.length - i); |
||||
break; |
||||
} |
||||
|
||||
m(consumed); |
||||
} else { |
||||
if(!consumed) { |
||||
break; |
||||
} |
||||
|
||||
m(1); |
||||
} |
||||
} else if(type === 'optional') { |
||||
if(result) { |
||||
c.push(...s, result); |
||||
s.length = 0; |
||||
m(consumed); |
||||
} |
||||
|
||||
++l; |
||||
} else if(type === 'formatting') { |
||||
if(!pushRest && i >= value.length) { |
||||
break; |
||||
} |
||||
|
||||
s.push(result); |
||||
++l; |
||||
m(consumed); |
||||
} |
||||
} |
||||
|
||||
if(pushRest) { |
||||
c.push(...s); |
||||
} |
||||
|
||||
return { |
||||
value: c.join(''), |
||||
selection: { |
||||
selectionStart: f === null || value.length && options.selectionStart === value.length ? accumulateLengths(c) : f, |
||||
selectionEnd: d === null || value.length && options.selectionEnd === value.length ? accumulateLengths(c) : d |
||||
}, |
||||
autocorrectComplete: l === length |
||||
}; |
||||
} |
||||
|
||||
export default formatValueByPattern; |
@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
|
||||
import { fixBuggedNumbers } from "../string/buggedNumbers"; |
||||
import replaceNonNumber from "../string/replaceNonNumber"; |
||||
|
||||
export type PatternCharacter = { |
||||
type: 'optional', |
||||
result: string, |
||||
consumed: number |
||||
} | { |
||||
type: 'required', |
||||
result: string, |
||||
consumed: number, |
||||
partial?: boolean |
||||
} | { |
||||
type: 'formatting', |
||||
result: string, |
||||
consumed: number |
||||
}; |
||||
|
||||
export type PatternFunction = (str: string) => ((str: string) => PatternCharacter)[]; |
||||
|
||||
function makeOptionalCharacter(result: string, consumed: number): PatternCharacter { |
||||
return {type: 'optional', result, consumed}; |
||||
} |
||||
|
||||
function makeRequiredCharacter(result: string, consumed: number, partial?: boolean): PatternCharacter { |
||||
return {type: 'required', result, consumed, partial}; |
||||
} |
||||
|
||||
function makeFormattingCharacter(result: string, consumed: number): PatternCharacter { |
||||
return {type: 'formatting', result, consumed}; |
||||
} |
||||
|
||||
function wrapCharacterRegExpFactory(regExp: RegExp, optional?: boolean) { |
||||
return (str: string) => { |
||||
const _regExp = new RegExp('^'.concat(regExp.source.replace(/^\^/, ''))); |
||||
const match = str.match(_regExp); |
||||
const makeCharacter = optional ? makeOptionalCharacter : makeRequiredCharacter; |
||||
if(match) { |
||||
const result = match[0]; |
||||
return makeCharacter(result, match.index + result.length); |
||||
} |
||||
|
||||
return makeCharacter('', str.length); |
||||
}; |
||||
} |
||||
|
||||
function makeCapitalPatternCharacter(str: string) { |
||||
const char = wrapCharacterRegExpFactory(/\w/)(str); |
||||
return char.result ? makeRequiredCharacter(char.result.toUpperCase(), char.consumed) : char; |
||||
} |
||||
|
||||
const makeMonthDigitPatternCharacter = wrapCharacterRegExpFactory(/1[0-2]|0?[1-9]|0/); |
||||
|
||||
function digit(str: string) { |
||||
return wrapCharacterRegExpFactory(/[0-9]/)(fixBuggedNumbers(str)); |
||||
} |
||||
|
||||
const patternCharacters = { |
||||
digit, |
||||
capitalCharacter: makeCapitalPatternCharacter, |
||||
month: (str: string) => { |
||||
const char = makeMonthDigitPatternCharacter(fixBuggedNumbers(str)); |
||||
const cleanedResult = replaceNonNumber(char.result); |
||||
const isPartial = ['0', '1'].includes(char.result) && str.length === 1; |
||||
if(isPartial || (char.result === '0' && str.length >= 2)) { |
||||
return makeRequiredCharacter(char.result, str.length, true); |
||||
} |
||||
|
||||
return makeRequiredCharacter(cleanedResult.length === 1 ? '0' + cleanedResult : cleanedResult, char.consumed); |
||||
}, |
||||
formattingCharacter: (str: string) => { |
||||
return (str1: string) => { |
||||
const consumed = str === str1[0] ? 1 : 0; |
||||
return makeFormattingCharacter(str, consumed); |
||||
} |
||||
}, |
||||
optionalPattern: (regExp: RegExp) => { |
||||
return (str: string) => { |
||||
const char = wrapCharacterRegExpFactory(regExp, true)(str); |
||||
return char.result ? char : makeOptionalCharacter('', 0); |
||||
}; |
||||
} |
||||
}; |
||||
|
||||
export default patternCharacters; |
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
import { CARD_BRANDS, detectCardBrand } from "./cardBrands"; |
||||
import formatInputValueByPattern from './formatInputValueByPattern'; |
||||
import NBSP from "../string/nbsp"; |
||||
import replaceNonNumber from "../string/replaceNonNumber"; |
||||
|
||||
export type PatternValidationOptions = Partial<{ |
||||
ignoreIncomplete: boolean |
||||
}>; |
||||
|
||||
const nbspRegExp = new RegExp(NBSP, 'g'); |
||||
|
||||
function makeValidationError(code?: string) { |
||||
return code ? { |
||||
type: 'invalid', |
||||
code |
||||
} : null; |
||||
} |
||||
|
||||
function validateCompleteCardNumber(card: string) { |
||||
const t = '0'.charCodeAt(0); |
||||
const n = card.length % 2; |
||||
let a = 0; |
||||
for(let i = card.length - 1; i >= 0; --i) { |
||||
const c = n === (i % 2); |
||||
let o = card.charCodeAt(i) - t; |
||||
if(c) o *= 2; |
||||
if(o > 9) o -= 9; |
||||
a += o; |
||||
} |
||||
return !(a % 10); |
||||
} |
||||
|
||||
function validateExpiry(year: number, month: number, options?: PatternValidationOptions) { |
||||
const date = new Date(Date.now()); |
||||
const _year = year < 100 ? date.getFullYear() % 100 : date.getFullYear(); |
||||
const nextMonth = date.getMonth() + 1; |
||||
|
||||
if(isNaN(year) || isNaN(month)) { |
||||
return options?.ignoreIncomplete ? null : 'incomplete'; |
||||
} |
||||
|
||||
if((year - _year) < 0) { |
||||
return 'invalid_expiry_year_past'; |
||||
} |
||||
|
||||
if((year - _year) > 50) { |
||||
return 'invalid_expiry_year'; |
||||
} |
||||
|
||||
return !(year - _year) && month < nextMonth ? 'invalid_expiry_month_past' : null; |
||||
} |
||||
|
||||
function getCardInfoByNumber(card: string) { |
||||
const sanitized = replaceNonNumber(card); |
||||
const brand = detectCardBrand(card); |
||||
return { |
||||
sanitized, |
||||
brand, |
||||
minLength: CARD_BRANDS[brand].minLength |
||||
}; |
||||
} |
||||
|
||||
function makeCardNumberError(str: string, length: number, ignoreIncomplete: boolean) { |
||||
return str.length >= length ? (validateCompleteCardNumber(str) ? null : makeValidationError('invalid')) : (ignoreIncomplete ? null : makeValidationError('incomplete')); |
||||
} |
||||
|
||||
export function validateCardNumber(str: string, options: PatternValidationOptions = {}) { |
||||
const {sanitized, minLength} = getCardInfoByNumber(str); |
||||
return makeCardNumberError(sanitized, minLength, options.ignoreIncomplete); |
||||
} |
||||
|
||||
export function validateCardExpiry(str: string, options: PatternValidationOptions = {}) { |
||||
const sanitized = str.replace(nbspRegExp, '').split(/ ?\/ ?/); |
||||
const [monthStr, yearStr = ''] = sanitized; |
||||
const [month, year] = [monthStr, yearStr].map((str) => +str); |
||||
const s = yearStr.length === 2 ? year % 100 : year; |
||||
return yearStr.length < 2 || yearStr.length === 3 ? (options.ignoreIncomplete ? null : makeValidationError('incomplete')) : makeValidationError(validateExpiry(s, month, options)); |
||||
} |
||||
|
||||
export function validateAnyIncomplete(formatted: ReturnType<typeof formatInputValueByPattern>, str: string, options: PatternValidationOptions = {}) { |
||||
return formatted.meta.autocorrectComplete || options.ignoreIncomplete ? null : makeValidationError('incomplete'); |
||||
} |
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
export default function loadScript(url: string) { |
||||
const script = document.createElement('script'); |
||||
const promise = new Promise<HTMLScriptElement>((resolve) => { |
||||
script.onload = script.onerror = () => { |
||||
resolve(script); |
||||
}; |
||||
}); |
||||
script.src = url; |
||||
document.body.appendChild(script); |
||||
return promise; |
||||
} |
@ -1,7 +0,0 @@
@@ -1,7 +0,0 @@
|
||||
import bigInt from "big-integer"; |
||||
import intToUint from "../number/intToUint"; |
||||
|
||||
export default function longFromInts(high: number, low: number): string { |
||||
high = intToUint(high), low = intToUint(low); |
||||
return bigInt(high).shiftLeft(32).add(bigInt(low)).toString(10); |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
import bigInt from "big-integer"; |
||||
import intToUint from "../number/intToUint"; |
||||
|
||||
export default function ulongFromInts(high: number, low: number) { |
||||
high = intToUint(high), low = intToUint(low); |
||||
return bigInt(high).shiftLeft(32).add(bigInt(low)); |
||||
} |
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
const delta = '0'.charCodeAt(0) - '0'.charCodeAt(0); |
||||
const buggedRegExp = /[0-9]/g; |
||||
|
||||
// function hasBuggedNumbers(str: string) {
|
||||
// return !!str.match(a);
|
||||
// }
|
||||
|
||||
function getDistanceFromBuggedToNormal(char: string) { |
||||
return String.fromCharCode(char.charCodeAt(0) - delta); |
||||
} |
||||
|
||||
export function fixBuggedNumbers(str: string) { |
||||
return str.replace(buggedRegExp, getDistanceFromBuggedToNormal); |
||||
} |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
export default function replaceNonLatin(str: string) { |
||||
return str.replace(/[^A-Za-z0-9]/g, ""); |
||||
} |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
export default function replaceNonNumber(str: string) { |
||||
return str.replace(/\D/g, ''); |
||||
} |
@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { InputPaymentCredentials, PaymentRequestedInfo, PaymentsPaymentForm } from "../../layer"; |
||||
import { AppManager } from "./manager"; |
||||
import getServerMessageId from "./utils/messageId/getServerMessageId"; |
||||
|
||||
export default class AppPaymentsManager extends AppManager { |
||||
public getPaymentForm(peerId: PeerId, mid: number) { |
||||
return this.apiManager.invokeApi('payments.getPaymentForm', { |
||||
peer: this.appPeersManager.getInputPeerById(peerId), |
||||
msg_id: getServerMessageId(mid) |
||||
}).then((paymentForm) => { |
||||
this.appUsersManager.saveApiUsers(paymentForm.users); |
||||
|
||||
return paymentForm; |
||||
}); |
||||
} |
||||
|
||||
public getPaymentReceipt(peerId: PeerId, mid: number) { |
||||
return this.apiManager.invokeApi('payments.getPaymentReceipt', { |
||||
peer: this.appPeersManager.getInputPeerById(peerId), |
||||
msg_id: getServerMessageId(mid) |
||||
}).then((paymentForm) => { |
||||
this.appUsersManager.saveApiUsers(paymentForm.users); |
||||
|
||||
return paymentForm; |
||||
}); |
||||
} |
||||
|
||||
public validateRequestedInfo(peerId: PeerId, mid: number, info: PaymentRequestedInfo, save?: boolean) { |
||||
return this.apiManager.invokeApi('payments.validateRequestedInfo', { |
||||
save, |
||||
peer: this.appPeersManager.getInputPeerById(peerId), |
||||
msg_id: getServerMessageId(mid), |
||||
info |
||||
}); |
||||
} |
||||
|
||||
public sendPaymentForm( |
||||
peerId: PeerId, |
||||
mid: number, |
||||
formId: PaymentsPaymentForm['form_id'], |
||||
requestedInfoId: string, |
||||
shippingOptionId: string, |
||||
credentials: InputPaymentCredentials, |
||||
tipAmount?: number |
||||
) { |
||||
return this.apiManager.invokeApi('payments.sendPaymentForm', { |
||||
form_id: formId, |
||||
peer: this.appPeersManager.getInputPeerById(peerId), |
||||
msg_id: getServerMessageId(mid), |
||||
requested_info_id: requestedInfoId, |
||||
shipping_option_id: shippingOptionId, |
||||
credentials, |
||||
tip_amount: tipAmount || undefined |
||||
}).then((result) => { |
||||
if(result._ === 'payments.paymentResult') { |
||||
this.apiUpdatesManager.processUpdateMessage(result.updates); |
||||
} |
||||
|
||||
return result; |
||||
}); |
||||
} |
||||
|
||||
public clearSavedInfo(info?: boolean, credentials?: boolean) { |
||||
return this.apiManager.invokeApi('payments.clearSavedInfo', { |
||||
info, |
||||
credentials |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb |
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE |
||||
*/ |
||||
|
||||
.popup-payment { |
||||
$parent: ".popup"; |
||||
|
||||
#{$parent} { |
||||
&-container { |
||||
padding: 0; |
||||
width: 26.25rem; |
||||
max-width: 26.25rem; |
||||
|
||||
max-height: unquote('min(100%, 43.5rem)'); |
||||
border-radius: $border-radius-huge; |
||||
} |
||||
|
||||
&-header { |
||||
height: 3.5rem; |
||||
margin: 0; |
||||
padding: 0 1rem; |
||||
} |
||||
} |
||||
|
||||
&.is-loading .popup-container { |
||||
min-height: 26.25rem; |
||||
} |
||||
|
||||
.scrollable { |
||||
flex: 1 1 auto; |
||||
display: flex; |
||||
flex-direction: column; |
||||
} |
||||
|
||||
hr { |
||||
display: block !important; |
||||
} |
||||
|
||||
.input-field { |
||||
--height: 3rem; |
||||
margin: 1.25rem .5rem 0; |
||||
|
||||
&-input { |
||||
--padding: .75rem; |
||||
} |
||||
} |
||||
|
||||
.sidebar-left-section { |
||||
padding: 0 !important; |
||||
|
||||
&-name + .input-field, |
||||
&-name + .input-fields-row .input-field { |
||||
margin-top: .75rem; |
||||
} |
||||
|
||||
.row { |
||||
margin-top: .5rem; |
||||
} |
||||
|
||||
&-content { |
||||
margin: 0 .5rem !important; |
||||
} |
||||
|
||||
// &-container { |
||||
// &:last-child .sidebar-left-section { |
||||
// margin-bottom: 0; |
||||
// } |
||||
// } |
||||
} |
||||
|
||||
.select-wrapper { |
||||
max-height: 10rem; |
||||
box-shadow: var(--menu-box-shadow); |
||||
|
||||
li { |
||||
grid-template-columns: calc(26px + 2rem) 1fr; |
||||
height: 3rem; |
||||
} |
||||
} |
||||
|
||||
.payment-verification { |
||||
width: 100%; |
||||
min-height: 30rem; |
||||
border: none; |
||||
flex: 1 1 auto; |
||||
} |
||||
|
||||
.row { |
||||
border-radius: $row-border-radius; |
||||
} |
||||
} |
||||
|
||||
.payment-item { |
||||
width: 100%; |
||||
padding: 0 1.25rem; |
||||
|
||||
&-details { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
margin-bottom: 1rem; |
||||
// height: 6.25rem; |
||||
|
||||
// max-height: 100px; |
||||
overflow: hidden; |
||||
flex: 0 0 auto; |
||||
|
||||
&:last-child { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
&-photo { |
||||
width: 6.25rem; |
||||
height: 6.25rem; |
||||
flex: 0 0 auto; |
||||
border-radius: $border-radius-medium; |
||||
margin-right: 1rem; |
||||
// background-color: var(--secondary-color); |
||||
|
||||
.media-photo { |
||||
border-radius: inherit; |
||||
} |
||||
} |
||||
|
||||
&-lines { |
||||
flex: 1 1 auto; |
||||
display: flex; |
||||
flex-direction: column; |
||||
|
||||
&-title { |
||||
font-weight: var(--font-weight-bold); |
||||
// font-size: var(--font-size-20); |
||||
// line-height: var(--line-height-20); |
||||
// margin: 5px 0; |
||||
|
||||
font-size: var(--font-size-16); |
||||
line-height: var(--line-height-16); |
||||
} |
||||
|
||||
&-description, |
||||
&-bot-name { |
||||
// flex: 1 1 auto; |
||||
font-size: var(--font-size-14); |
||||
line-height: 1.25rem; |
||||
// color: var(--secondary-text-color); |
||||
|
||||
// @include text-overflow(false); |
||||
} |
||||
|
||||
&-bot-name { |
||||
color: var(--secondary-text-color); |
||||
// flex: 0 0 auto; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&-prices { |
||||
display: flex; |
||||
flex-direction: column; |
||||
margin: 1rem .25rem; |
||||
|
||||
&-price { |
||||
color: var(--secondary-text-color); |
||||
font-weight: 500; |
||||
display: flex; |
||||
justify-content: space-between; |
||||
line-height: 1.1875rem; |
||||
|
||||
& + & { |
||||
margin-top: 1.5rem; |
||||
} |
||||
|
||||
&.is-total { |
||||
color: var(--primary-text-color); |
||||
} |
||||
} |
||||
} |
||||
|
||||
&-tips { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
margin: .75rem -.5rem 1.5rem; |
||||
|
||||
&-tip { |
||||
--background-intensity: .1; |
||||
flex: 1 1 auto; |
||||
text-align: center; |
||||
height: 2.5rem; |
||||
border-radius: 1.25rem; |
||||
background-color: rgba(84, 190, 97, var(--background-intensity)); |
||||
color: #3ba748; |
||||
font-weight: var(--font-weight-bold); |
||||
font-size: var(--font-size-16); |
||||
line-height: 2.5rem; |
||||
|
||||
@include animation-level(2) { |
||||
transition: color .1s ease-in-out, background-color .1s ease-in-out; |
||||
} |
||||
|
||||
& + & { |
||||
margin-left: .5rem; |
||||
} |
||||
|
||||
&:not(.active) { |
||||
@include hover() { |
||||
--background-intensity: .3; |
||||
} |
||||
} |
||||
|
||||
&.active { |
||||
--background-intensity: 1; |
||||
color: #fff; |
||||
} |
||||
} |
||||
|
||||
&-input { |
||||
color: inherit !important; |
||||
font-weight: inherit !important; |
||||
// text-align: right; |
||||
display: inline; |
||||
} |
||||
} |
||||
|
||||
&-row { |
||||
margin: 0 .5rem; |
||||
padding-top: 0; |
||||
padding-bottom: 0; |
||||
} |
||||
|
||||
&-method-row { |
||||
.media-photo { |
||||
border-radius: $border-radius-medium; |
||||
} |
||||
} |
||||
|
||||
&-pay { |
||||
flex: 0 0 auto; |
||||
width: auto; |
||||
height: 3rem; |
||||
margin: 1rem; |
||||
text-transform: uppercase; |
||||
} |
||||
|
||||
&-preloader-container { |
||||
position: relative; |
||||
flex: 1 1 auto; |
||||
} |
||||
} |
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb |
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE |
||||
*/ |
||||
|
||||
// .popup-payment-card { |
||||
// .popup-container { |
||||
// max-height: 26.25rem; |
||||
// } |
||||
// } |
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb |
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE |
||||
*/ |
||||
|
||||
.popup-payment-card-confirmation { |
||||
.popup-container { |
||||
min-height: auto; |
||||
} |
||||
|
||||
.input-field-password { |
||||
margin-top: .5rem !important; |
||||
} |
||||
} |
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb |
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE |
||||
*/ |
||||
|
||||
.popup-payment-shipping-methods { |
||||
.popup-container { |
||||
min-height: auto; |
||||
} |
||||
|
||||
.row { |
||||
margin-top: 0 !important; |
||||
} |
||||
} |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb |
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE |
||||
*/ |
||||
|
||||
// .popup-payment-verification { |
||||
|
||||
// } |
@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
|
||||
import cardFormattingPatterns from "../helpers/cards/cardFormattingPatterns"; |
||||
import formatValueByPattern from "../helpers/cards/formatValueByPattern"; |
||||
import { validateCardExpiry, validateCardNumber } from "../helpers/cards/validateCard"; |
||||
|
||||
describe('Card number', () => { |
||||
test('Format', () => { |
||||
const data = [ |
||||
['4242424242424242', '4242 4242 4242 4242'], |
||||
['371758885524003', '3717 588855 24003'] |
||||
]; |
||||
|
||||
data.forEach(([plain, formatted]) => { |
||||
const result = formatValueByPattern(cardFormattingPatterns.cardNumber, plain); |
||||
expect(result.value).toEqual(formatted); |
||||
}); |
||||
}); |
||||
|
||||
test('Validate', () => { |
||||
const data = [ |
||||
['4242424242424242', null], |
||||
['4242424242424241', 'invalid'], |
||||
['424242424242424', 'incomplete'] |
||||
]; |
||||
|
||||
data.forEach(([cardNumber, code]) => { |
||||
const result = validateCardNumber(cardNumber); |
||||
if(code) { |
||||
expect(result.code).toEqual(code); |
||||
} else { |
||||
expect(result).toEqual(null); |
||||
} |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('Expiry date', () => { |
||||
const joiner = '/'; |
||||
const getExpiryDate = (date: Date) => `${date.getMonth()}${joiner}${date.getFullYear() % 100}`; |
||||
|
||||
test('Format', () => { |
||||
const month = 10; |
||||
const year = 20; |
||||
|
||||
const {value} = formatValueByPattern(cardFormattingPatterns.cardExpiry, `${month}${year}`); |
||||
expect(value).toEqual(`${month}${joiner}${year}`); |
||||
}); |
||||
|
||||
test('Expired', () => { |
||||
const date = new Date(); |
||||
date.setMonth(date.getMonth() - 1); |
||||
expect(validateCardExpiry(getExpiryDate(date))).toBeTruthy(); |
||||
}); |
||||
|
||||
test('Nonexpired', () => { |
||||
const date = new Date(); |
||||
date.setFullYear(date.getFullYear() + 1); |
||||
expect(validateCardExpiry(getExpiryDate(date))).toEqual(null); |
||||
}); |
||||
}); |
Loading…
Reference in new issue