diff --git a/components/src/widget/views/amount/amount.css b/components/src/widget/views/amount/amount.css new file mode 100644 index 00000000..2faef526 --- /dev/null +++ b/components/src/widget/views/amount/amount.css @@ -0,0 +1,115 @@ +:host { + font-family: var(--wm-font-family, system-ui, sans-serif); + display: block; + flex: 0; + + --primary-color: var(--wm-primary-color, #56b7b5); + --background-color: var(--wm-background-color, #ffffff); + --text-color: var(--wm-text-color, #363636); +} + +fieldset { + border: 0; + padding: 0; + margin: 0; + width: 100%; +} + +.preset-buttons { + display: flex; + justify-content: center; + gap: 12px; + margin: var(--Spacings-lg, 24px) 0 var(--Spacings-lg, 24px) 0; +} + +.preset-buttons button { + display: flex; + width: calc(var(--Font-Size-text-base) + 2.75rem); + height: calc(var(--Font-Size-text-base) + 2.75rem); + cursor: pointer; + padding: 16px 12px; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 100px; + border: 1px solid var(--text-color, #c9c9c9); + + background: var(--background-color, #f2fbf9); + color: var(--Text-paragraph-standard, #7b7b7b); + font-family: var(--Font-Family-Inter, Inter); + font-size: var(--Font-Size-text-base, 16px); + font-style: normal; + font-weight: var(--Font-Weight-Bold, 700); + line-height: var(--Font-Line-Height-md, 24px); +} + +.preset-buttons button:hover { + background: #0000002f; +} + +.preset-buttons [aria-checked='true'] { + background: color-mix( + in srgb, + var(--primary-color, #f2fbf9) 45%, + transparent + ); + border: 1px solid var(--primary-color, #5b5380); +} + +.currency-symbol { + position: absolute; + left: var(--Spacings-md, 12px); + top: 50%; + transform: translateY(-50%); + color: var(--Text-paragraph-standard, #363636); + font-family: var(--Font-Family-Inter, Inter); + font-size: var(--Font-Size-text-base, 16px); + font-weight: var(--Font-Weight-Regular, 400); + pointer-events: none; + z-index: 1; +} + +.form-input { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + align-self: stretch; +} + +.form-input[aria-invalid='true'] { + border-color: var(--Colors-red-500, #ef4444); +} + +.form-label { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 2px; + color: var(--Text-paragraph-standard, #363636); + font-family: var(--Font-Family-Inter, Inter); + font-size: var(--Font-Size-text-xs, 12px); + font-style: normal; + font-weight: var(--Font-Weight-Regular, 400); + line-height: var(--Font-Line-Height-xs, 16px); +} + +.amount-input-wrapper { + position: relative; + width: 100%; + margin-top: 4px; +} + +.form-input.with-currency { + padding-left: calc(var(--Font-Size-text-base) + 2rem); + border-width: 2px; +} + +.amount-error { + color: var(--Colors-red-500, #ef4444); + font-family: var(--Font-Family-Inter, Inter); + font-size: var(--Font-Size-text-sm, 14px); + font-style: normal; + font-weight: var(--Font-Weight-Regular, 400); + line-height: var(--Font-Line-Height-sm, 20px); +} diff --git a/components/src/widget/views/amount/amount.ts b/components/src/widget/views/amount/amount.ts new file mode 100644 index 00000000..4f5dbcbd --- /dev/null +++ b/components/src/widget/views/amount/amount.ts @@ -0,0 +1,148 @@ +import { LitElement, html, nothing, unsafeCSS } from 'lit' +import { property, query, state } from 'lit/decorators.js' +import { getCurrencySymbol } from '@c/utils' +import styles from './amount.css?raw' +import stylesBase from '../confirmation/confirmation.css?raw' + +export interface AmountChangeEventDetail { + amount: number + onComplete: (error?: string | null) => void +} + +export class PaymentAmount extends LitElement { + #debounceTimer: ReturnType | null = null + + @property({ type: String }) currency = 'USD' + @property({ type: Array }) presets: number[] = [1, 5, 10] + @property({ type: Number }) value = 0 + + @query('input') private _inputEl!: HTMLInputElement + + @state() private _errorMsg: string | null = null + + static styles = [unsafeCSS(stylesBase), unsafeCSS(styles)] + + connectedCallback(): void { + super.connectedCallback() + } + + protected firstUpdated() { + this._inputEl.focus() + } + + private _handleInput(e: Event) { + const el = e.target as HTMLInputElement + const rawValue = el.value + + // assumes assetScale=2 here + const validFormat = /^\d*\.?\d{0,2}$/.test(rawValue) + if (!validFormat) { + el.value = this.value === 0 ? '' : this.value.toString() + return + } + + const newValue = Number.parseFloat(rawValue) || 0 + if (newValue === this.value) return + if (Number.isNaN(newValue)) return + + this.value = newValue + + this._errorMsg = null + this._processUpdate() + } + + private _handlePresetClick(presetAmount: number) { + this._errorMsg = null + this.value = presetAmount + this._processUpdate(true) + } + + private _processUpdate(immediate = false) { + if (this.#debounceTimer) clearTimeout(this.#debounceTimer) + this.#debounceTimer = setTimeout( + () => this.onChange(this.value), + immediate ? 0 : 750, + ) + } + + private onChange(amount: number) { + this.dispatchEvent( + new CustomEvent('change', { + detail: { + amount, + onComplete: (error) => (this._errorMsg = error || null), + }, + }), + ) + } + + render() { + const currencySymbol = getCurrencySymbol(this.currency) + const hasError = !!this._errorMsg + + return html` +
+ Amount + +
+ ${currencySymbol} + ev.preventDefault()} + autocomplete="off" + spellcheck="false" + /> +
+ + + +
+ ${this.presets.map( + (amount) => html` + + `, + )} +
+ ` + } +} + +function allowOnlyNumericInput( + ev: KeyboardEvent & { currentTarget: HTMLInputElement }, +) { + if (ev.key.length > 1 || ev.ctrlKey || ev.metaKey) return + if (ev.key === 'Tab') return + if ( + !charIsNumber(ev.key) || + (ev.key === '.' && ev.currentTarget.value.includes('.')) + ) { + ev.preventDefault() + } +} + +function charIsNumber(char?: string) { + return !!(char || '').match(/\d|\./) +} diff --git a/components/src/widget/views/confirmation/confirmation.css b/components/src/widget/views/confirmation/confirmation.css index 91456f7c..a467c40a 100644 --- a/components/src/widget/views/confirmation/confirmation.css +++ b/components/src/widget/views/confirmation/confirmation.css @@ -69,13 +69,6 @@ flex: 1 1 auto; } -.preset-buttons { - display: flex; - justify-content: center; - gap: 12px; - margin: var(--Spacings-lg, 24px) 0 var(--Spacings-lg, 24px) 0; -} - .enter-amount-description { color: var(--Text-paragraph-standard, #363636); font-family: var(--Font-Family-Inter, Inter); @@ -85,40 +78,6 @@ line-height: var(--Font-Line-Height-sm, 20px); } -.preset-btn { - display: flex; - width: calc(var(--Font-Size-text-base) + 2.75rem); - height: calc(var(--Font-Size-text-base) + 2.75rem); - cursor: pointer; - padding: 16px 12px; - justify-content: center; - align-items: center; - gap: 10px; - border-radius: 100px; - border: 1px solid var(--text-color, #c9c9c9); - - background: var(--background-color, #f2fbf9); - color: var(--Text-paragraph-standard, #7b7b7b); - font-family: var(--Font-Family-Inter, Inter); - font-size: var(--Font-Size-text-base, 16px); - font-style: normal; - font-weight: var(--Font-Weight-Bold, 700); - line-height: var(--Font-Line-Height-md, 24px); -} - -.preset-btn:hover { - background: #0000002f; -} - -.preset-btn.selected { - background: color-mix( - in srgb, - var(--primary-color, #f2fbf9) 45%, - transparent - ); - border: 1px solid var(--primary-color, #5b5380); -} - .widget-body p, .widget-body label:not(.form-label) { color: var(--Text-paragraph-standard, #363636); @@ -136,19 +95,6 @@ height: 100%; } -.currency-symbol { - position: absolute; - left: var(--Spacings-md, 12px); - top: 50%; - transform: translateY(-50%); - color: var(--Text-paragraph-standard, #363636); - font-family: var(--Font-Family-Inter, Inter); - font-size: var(--Font-Size-text-base, 16px); - font-weight: var(--Font-Weight-Regular, 400); - pointer-events: none; - z-index: 1; -} - .payment-details { display: flex; padding: var(--Spacings-md, 12px); @@ -352,16 +298,6 @@ line-height: var(--Font-Line-Height-xs, 16px); } -.amount-input-wrapper { - position: relative; - width: 100%; -} - -.form-input.with-currency { - padding-left: calc(var(--Font-Size-text-base) + 2rem); - border: 2px solid var(--Colors-silver-300, #c9c9c9); -} - .form-input, .payment-note-input { width: 100%; diff --git a/components/src/widget/views/confirmation/confirmation.ts b/components/src/widget/views/confirmation/confirmation.ts index 908c3d18..ef1b6875 100644 --- a/components/src/widget/views/confirmation/confirmation.ts +++ b/components/src/widget/views/confirmation/confirmation.ts @@ -9,11 +9,9 @@ import { type Controller, type WidgetController, } from '@c/widget/controller' -import type { Amount } from '@shared/types' import { toAmount } from '@shared/utils' import confirmationCss from './confirmation.css?raw' - -const MIN_SEND_AMOUNT = 1 // 1 unit +import { type AmountChangeEventDetail, PaymentAmount } from '../amount/amount' export class PaymentConfirmation extends LitElement { @property({ type: Object }) configController!: WidgetController @@ -22,11 +20,10 @@ export class PaymentConfirmation extends LitElement { @state() private inputAmount = '' @state() private isLoadingPreview = false @state() private isPreparingPayment = false - @state() private debounceTimer: ReturnType | null = null @state() private amountError: string | null = null @state() private formattedDebitAmount?: string @state() private formattedReceiveAmount?: string - #minSendAmount?: Amount + #minSendAmount?: number static styles = unsafeCSS(confirmationCss) @@ -38,14 +35,9 @@ export class PaymentConfirmation extends LitElement { if (!customElements.get('wm-close-btn')) { customElements.define('wm-close-btn', CloseBtn) } - - this.updateComplete.then(() => { - const input = - this.shadowRoot?.querySelector('#amount-input') - if (input) { - input.focus() - } - }) + if (!customElements.get('wm-amount')) { + customElements.define('wm-amount', PaymentAmount) + } } #controller = NO_OP_CONTROLLER @@ -58,62 +50,40 @@ export class PaymentConfirmation extends LitElement { this.#controller = controller } - private debouncedProcessPayment(amount: string) { - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) - } - - const amountToSend = Number(amount) - - if (this.#minSendAmount) { - const { value, assetScale } = this.#minSendAmount - const minAmount = Number(value) / 10 ** assetScale - - if (amountToSend < minAmount) { - const { assetScale } = this.configController.state.walletAddress - this.amountError = this.validateAmount( - amountToSend * 10 ** assetScale, - Number(value), - ) - return - } - } - + private onAmountChange(ev: CustomEvent) { this.amountError = null - this.isLoadingPreview = true - this.debounceTimer = setTimeout(() => { - this.processPaymentForAmount(amountToSend) - }, 750) - } - - private async processPaymentForAmount(amount: number) { + const { amount, onComplete } = ev.detail const { walletAddress: sender, receiver } = this.configController.state - const assetScale = sender.assetScale - amount = Math.max(amount, MIN_SEND_AMOUNT / 10 ** assetScale) - - this.configController.updateState({ amount }) - await this.getPaymentQuote({ sender, receiver, amount }) - this.isLoadingPreview = false - } + const formatted = this.formatAmount(String(amount)) + this.inputAmount = formatted - private handlePresetClick(amount: string) { - this.inputAmount = amount + this.configController.updateState({ amount }) - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) + if (amount <= 0 || (this.#minSendAmount && amount < this.#minSendAmount)) { + if (this.#minSendAmount) { + const minSendAmount = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: sender.assetCode, + }).format(amount) + this.amountError = `Please enter an amount greater than ${minSendAmount}` + } else { + this.amountError = `Please enter a higher amount.` + } + onComplete(this.amountError) + return } - this.debouncedProcessPayment(amount) - } - - private handleAmountInput(e: Event) { - const input = e.target as HTMLInputElement - - const formatted = this.formatAmount(input.value) - this.inputAmount = formatted - this.debouncedProcessPayment(this.inputAmount) + this.isLoadingPreview = true this.requestUpdate() + + void this.getPaymentQuote({ sender, receiver, amount }) + .then(() => onComplete(this.amountError)) + .catch((error) => onComplete((error as Error).message)) + .finally(() => { + this.isLoadingPreview = false + this.requestUpdate() + }) } private handleNoteInput(e: Event) { @@ -145,29 +115,6 @@ export class PaymentConfirmation extends LitElement { }).format(number) } - private handleKeyDown(e: KeyboardEvent) { - // allow only: backspace (8) and delete (46) - if ([8, 46, 37, 39].includes(e.keyCode)) { - return - } - - if ( - // allow only numbers (48-57, 96-105) and decimal point (190, 110) - (e.shiftKey || e.keyCode < 48 || e.keyCode > 57) && - (e.keyCode < 96 || e.keyCode > 105) && - e.keyCode !== 190 && - e.keyCode !== 110 - ) { - e.preventDefault() - } - - // only allow one decimal point - const input = e.target as HTMLInputElement - if ((e.keyCode === 190 || e.keyCode === 110) && input.value.includes('.')) { - e.preventDefault() - } - } - validateAmount(amountToScale: number, minToScale: number): string | null { const val = Number(amountToScale) if (Number.isNaN(val)) { @@ -201,7 +148,7 @@ export class PaymentConfirmation extends LitElement { } const value = data.minSendAmount.value // Rafiki v1.2.0-beta and later include `minSendAmount` with error - this.#minSendAmount = toAmount(value, sender) + this.#minSendAmount = Number(toAmount(value, sender).value) // TODO: in validateAmount, remove concept of assetScale this.amountError = this.validateAmount( Number(amount) * 10 ** sender.assetScale, @@ -283,16 +230,12 @@ export class PaymentConfirmation extends LitElement { disconnectedCallback() { super.disconnectedCallback() - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) - } } render() { const { walletAddress: { assetCode }, } = this.configController.state - const currencySymbol = getCurrencySymbol(assetCode) return html`
@@ -313,56 +256,10 @@ export class PaymentConfirmation extends LitElement {
-
- - -
- ${currencySymbol} - e.preventDefault()} - @keydown=${this.handleKeyDown} - autocomplete="off" - spellcheck="false" - aria-invalid=${!!this.amountError} - aria-describedby=${this.amountError ? 'amount-error' : nothing} - /> -
- ${this.amountError - ? html`` - : nothing} -
- -
- - - -
+ ${this.inputAmount ? this.renderPaymentDetails()