From ba1ee15286c1bb79401b56eaee2fbd6ca2b58adb Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 22 Dec 2024 12:27:29 +0000 Subject: [PATCH 1/3] [accelerator] improve SCA UX --- .../accelerate-checkout.component.html | 9 +- .../accelerate-checkout.component.scss | 7 + .../accelerate-checkout.component.ts | 221 ++++++++++-------- 3 files changed, 135 insertions(+), 102 deletions(-) diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html index df67de65c..cfe2beec0 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -1,4 +1,4 @@ -
+
@if (accelerateError) {
@@ -361,7 +361,7 @@

Payment to mempool.space for acceleration of txid {{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}

- @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp)) { + @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {

Your account will be debited no more than {{ cost | number }} sats

@@ -484,6 +484,11 @@
}
+ @if (tokenizing) { +
+
+
+ }
diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss index ad085ed20..75c6a397d 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss @@ -8,6 +8,13 @@ color: var(--green) } +.accelerate-checkout-inner { + &.input-disabled { + pointer-events: none; + opacity: 0.75; + } +} + .paymentMethod { padding: 10px; background-color: var(--secondary); diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index d6ac7f54f..236326e0d 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -76,6 +76,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { calculating = true; processing = false; + checkoutLocked = false; + tokenizing = false; selectedOption: 'wait' | 'accel'; cantPayReason = ''; quoteError = ''; // error fetching estimate or initial data @@ -504,55 +506,64 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.loadingApplePay = false; applePayButton.addEventListener('click', async event => { event.preventDefault(); - const tokenResult = await this.applePay.tokenize(); - if (tokenResult?.status === 'OK') { - const card = tokenResult.details?.card; - if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { - console.error(`Cannot retreive payment card details`); - this.accelerateError = 'apple_pay_no_card_details'; - this.processing = false; - return; - } - const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); - this.servicesApiService.accelerateWithApplePay$( - this.tx.txid, - tokenResult.token, - cardTag, - `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - costUSD - ).subscribe({ - next: () => { + try { + // lock the checkout UI and show a loading spinner until the square modals are finished + this.checkoutLocked = true; + this.tokenizing = true; + const tokenResult = await this.applePay.tokenize(); + if (tokenResult?.status === 'OK') { + const card = tokenResult.details?.card; + if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { + console.error(`Cannot retreive payment card details`); + this.accelerateError = 'apple_pay_no_card_details'; this.processing = false; - this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); - this.audioService.playSound('ascend-chime-cartoon'); - if (this.applePay) { - this.applePay.destroy(); - } - setTimeout(() => { - this.moveToStep('paid'); - }, 1000); - }, - error: (response) => { - this.processing = false; - this.accelerateError = response.error; - if (!(response.status === 403 && response.error === 'not_available')) { - setTimeout(() => { - // Reset everything by reloading the page :D, can be improved - const urlParams = new URLSearchParams(window.location.search); - window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``)); - }, 3000); - } + return; } - }); - } else { - this.processing = false; - let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; - if (tokenResult.errors) { - errorMessage += ` and errors: ${JSON.stringify( - tokenResult.errors, - )}`; + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + this.servicesApiService.accelerateWithApplePay$( + this.tx.txid, + tokenResult.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + costUSD + ).subscribe({ + next: () => { + this.processing = false; + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + if (this.applePay) { + this.applePay.destroy(); + } + setTimeout(() => { + this.moveToStep('paid'); + }, 1000); + }, + error: (response) => { + this.processing = false; + this.accelerateError = response.error; + if (!(response.status === 403 && response.error === 'not_available')) { + setTimeout(() => { + // Reset everything by reloading the page :D, can be improved + const urlParams = new URLSearchParams(window.location.search); + window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``)); + }, 3000); + } + } + }); + } else { + this.processing = false; + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; + } + throw new Error(errorMessage); } - throw new Error(errorMessage); + } finally { + // always unlock the checkout once we're finished + this.tokenizing = false; + this.checkoutLocked = false; } }); } catch (e) { @@ -603,63 +614,73 @@ export class AccelerateCheckout implements OnInit, OnDestroy { document.getElementById('google-pay-button').addEventListener('click', async event => { event.preventDefault(); - const tokenResult = await this.googlePay.tokenize(); - if (tokenResult?.status === 'OK') { - const card = tokenResult.details?.card; - if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { - console.error(`Cannot retreive payment card details`); - this.accelerateError = 'apple_pay_no_card_details'; - this.processing = false; - return; - } - const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2)); - if (!verificationToken) { - console.error(`SCA verification failed`); - this.accelerateError = 'SCA Verification Failed. Payment Declined.'; - this.processing = false; - return; - } - const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); - this.servicesApiService.accelerateWithGooglePay$( - this.tx.txid, - tokenResult.token, - verificationToken, - cardTag, - `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - costUSD - ).subscribe({ - next: () => { + try { + // lock the checkout UI and show a loading spinner until the square modals are finished + this.checkoutLocked = true; + this.tokenizing = true; + const tokenResult = await this.googlePay.tokenize(); + tokenResult.token = 'ccof:customer-card-id-requires-verification'; + if (tokenResult?.status === 'OK') { + const card = tokenResult.details?.card; + if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { + console.error(`Cannot retreive payment card details`); + this.accelerateError = 'apple_pay_no_card_details'; this.processing = false; - this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); - this.audioService.playSound('ascend-chime-cartoon'); - if (this.googlePay) { - this.googlePay.destroy(); - } - setTimeout(() => { - this.moveToStep('paid'); - }, 1000); - }, - error: (response) => { - this.processing = false; - this.accelerateError = response.error; - if (!(response.status === 403 && response.error === 'not_available')) { - setTimeout(() => { - // Reset everything by reloading the page :D, can be improved - const urlParams = new URLSearchParams(window.location.search); - window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``)); - }, 3000); - } + return; } - }); - } else { - this.processing = false; - let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; - if (tokenResult.errors) { - errorMessage += ` and errors: ${JSON.stringify( - tokenResult.errors, - )}`; + const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2)); + if (!verificationToken) { + console.error(`SCA verification failed`); + this.accelerateError = 'SCA Verification Failed. Payment Declined.'; + this.processing = false; + return; + } + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + this.servicesApiService.accelerateWithGooglePay$( + this.tx.txid, + tokenResult.token, + verificationToken, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + costUSD + ).subscribe({ + next: () => { + this.processing = false; + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); + this.audioService.playSound('ascend-chime-cartoon'); + if (this.googlePay) { + this.googlePay.destroy(); + } + setTimeout(() => { + this.moveToStep('paid'); + }, 1000); + }, + error: (response) => { + this.processing = false; + this.accelerateError = response.error; + if (!(response.status === 403 && response.error === 'not_available')) { + setTimeout(() => { + // Reset everything by reloading the page :D, can be improved + const urlParams = new URLSearchParams(window.location.search); + window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``)); + }, 3000); + } + } + }); + } else { + this.processing = false; + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; + } + throw new Error(errorMessage); } - throw new Error(errorMessage); + } finally { + // always unlock the checkout once we're finished + this.tokenizing = false; + this.checkoutLocked = false; } }); } From 464fabf137a1deb6021b8c3bd9cc472d12c05c45 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 22 Dec 2024 12:27:29 +0000 Subject: [PATCH 2/3] [accelerator] reference counting for checkout lock --- .../accelerate-checkout.component.html | 4 +-- .../accelerate-checkout.component.ts | 35 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html index cfe2beec0..150da04da 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -1,4 +1,4 @@ -
+
@if (accelerateError) {
@@ -484,7 +484,7 @@
}
- @if (tokenizing) { + @if (isTokenizing > 0) {
diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index 236326e0d..be40b92b1 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -76,8 +76,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { calculating = true; processing = false; - checkoutLocked = false; - tokenizing = false; + isCheckoutLocked = 0; // reference counter, 0 = unlocked, >0 = locked + isTokenizing = 0; // reference counter, 0 = false, >0 = true selectedOption: 'wait' | 'accel'; cantPayReason = ''; quoteError = ''; // error fetching estimate or initial data @@ -508,8 +508,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { event.preventDefault(); try { // lock the checkout UI and show a loading spinner until the square modals are finished - this.checkoutLocked = true; - this.tokenizing = true; + this.isCheckoutLocked++; + this.isTokenizing++; const tokenResult = await this.applePay.tokenize(); if (tokenResult?.status === 'OK') { const card = tokenResult.details?.card; @@ -520,6 +520,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy { return; } const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + // keep checkout in loading state until the acceleration request completes + this.isTokenizing++; + this.isCheckoutLocked++; this.servicesApiService.accelerateWithApplePay$( this.tx.txid, tokenResult.token, @@ -535,12 +538,16 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.applePay.destroy(); } setTimeout(() => { + this.isTokenizing--; + this.isCheckoutLocked--; this.moveToStep('paid'); }, 1000); }, error: (response) => { this.processing = false; this.accelerateError = response.error; + this.isTokenizing--; + this.isCheckoutLocked--; if (!(response.status === 403 && response.error === 'not_available')) { setTimeout(() => { // Reset everything by reloading the page :D, can be improved @@ -562,8 +569,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } } finally { // always unlock the checkout once we're finished - this.tokenizing = false; - this.checkoutLocked = false; + this.isTokenizing--; + this.isCheckoutLocked--; } }); } catch (e) { @@ -616,10 +623,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy { event.preventDefault(); try { // lock the checkout UI and show a loading spinner until the square modals are finished - this.checkoutLocked = true; - this.tokenizing = true; + this.isCheckoutLocked++; + this.isTokenizing++; const tokenResult = await this.googlePay.tokenize(); - tokenResult.token = 'ccof:customer-card-id-requires-verification'; if (tokenResult?.status === 'OK') { const card = tokenResult.details?.card; if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { @@ -636,6 +642,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy { return; } const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + // keep checkout in loading state until the acceleration request completes + this.isCheckoutLocked++; + this.isTokenizing++; this.servicesApiService.accelerateWithGooglePay$( this.tx.txid, tokenResult.token, @@ -646,6 +655,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { ).subscribe({ next: () => { this.processing = false; + this.isTokenizing--; + this.isCheckoutLocked--; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); if (this.googlePay) { @@ -658,6 +669,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { error: (response) => { this.processing = false; this.accelerateError = response.error; + this.isTokenizing--; + this.isCheckoutLocked--; if (!(response.status === 403 && response.error === 'not_available')) { setTimeout(() => { // Reset everything by reloading the page :D, can be improved @@ -679,8 +692,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } } finally { // always unlock the checkout once we're finished - this.tokenizing = false; - this.checkoutLocked = false; + this.isTokenizing--; + this.isCheckoutLocked--; } }); } From f49152d09dc46a05d7bf122356bdbe2ed58dad66 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 22 Dec 2024 15:17:39 +0000 Subject: [PATCH 3/3] [accelerator] keep checkout locked until request completes --- .../accelerate-checkout.component.ts | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index be40b92b1..a69b7b107 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -156,7 +156,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.accelerateError = null; this.timePaid = 0; this.btcpayInvoiceFailed = false; - this.moveToStep('summary'); + this.moveToStep('summary', true); } else { this.auth = auth; } @@ -165,11 +165,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy { const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('cash_request_id')) { // Redirected from cashapp - this.moveToStep('processing'); + this.moveToStep('processing', true); this.insertSquare(); this.setupSquare(); } else { - this.moveToStep('summary'); + this.moveToStep('summary', true); } this.conversionsSubscription = this.stateService.conversions$.subscribe( @@ -194,14 +194,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } if (changes.accelerating && this.accelerating) { if (this.step === 'processing' || this.step === 'paid') { - this.moveToStep('success'); + this.moveToStep('success', true); } else { // Edge case where the transaction gets accelerated by someone else or on another session this.closeModal(); } } } - moveToStep(step: CheckoutStep): void { + moveToStep(step: CheckoutStep, force: boolean = false): void { + if (this.isCheckoutLocked > 0 && !force) { + return; + } this.processing = false; this._step = step; if (this.timeoutTimer) { @@ -244,7 +247,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { closeModal(): void { this.completed.emit(true); - this.moveToStep('summary'); + this.moveToStep('summary', true); } /** @@ -395,7 +398,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.audioService.playSound('ascend-chime-cartoon'); this.showSuccess = true; this.estimateSubscription.unsubscribe(); - this.moveToStep('paid'); + this.moveToStep('paid', true); }, error: (response) => { this.processing = false; @@ -505,6 +508,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } this.loadingApplePay = false; applePayButton.addEventListener('click', async event => { + if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { + return; + } event.preventDefault(); try { // lock the checkout UI and show a loading spinner until the square modals are finished @@ -540,16 +546,16 @@ export class AccelerateCheckout implements OnInit, OnDestroy { setTimeout(() => { this.isTokenizing--; this.isCheckoutLocked--; - this.moveToStep('paid'); + this.moveToStep('paid', true); }, 1000); }, error: (response) => { this.processing = false; this.accelerateError = response.error; - this.isTokenizing--; - this.isCheckoutLocked--; if (!(response.status === 403 && response.error === 'not_available')) { setTimeout(() => { + this.isTokenizing--; + this.isCheckoutLocked--; // Reset everything by reloading the page :D, can be improved const urlParams = new URLSearchParams(window.location.search); window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``)); @@ -620,6 +626,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.loadingGooglePay = false; document.getElementById('google-pay-button').addEventListener('click', async event => { + if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { + return; + } event.preventDefault(); try { // lock the checkout UI and show a loading spinner until the square modals are finished @@ -655,15 +664,15 @@ export class AccelerateCheckout implements OnInit, OnDestroy { ).subscribe({ next: () => { this.processing = false; - this.isTokenizing--; - this.isCheckoutLocked--; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); if (this.googlePay) { this.googlePay.destroy(); } setTimeout(() => { - this.moveToStep('paid'); + this.isTokenizing--; + this.isCheckoutLocked--; + this.moveToStep('paid', true); }, 1000); }, error: (response) => { @@ -760,7 +769,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.cashAppPay.destroy(); } setTimeout(() => { - this.moveToStep('paid'); + this.moveToStep('paid', true); if (window.history.replaceState) { const urlParams = new URLSearchParams(window.location.search); window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, '')); @@ -834,7 +843,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); this.estimateSubscription.unsubscribe(); - this.moveToStep('paid'); + this.moveToStep('paid', true); } isLoggedIn(): boolean {