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..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) {
@@ -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 (isTokenizing > 0) { +
+
+
+ }
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 4c935c57f..34a98b8dc 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; + 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 @@ -154,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; } @@ -163,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( @@ -192,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) { @@ -242,7 +247,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { closeModal(): void { this.completed.emit(true); - this.moveToStep('summary'); + this.moveToStep('summary', true); } /** @@ -393,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; @@ -503,56 +508,75 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } this.loadingApplePay = false; applePayButton.addEventListener('click', async event => { + if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) { + return; + } 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.isCheckoutLocked++; + this.isTokenizing++; + 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()); + // keep checkout in loading state until the acceleration request completes + this.isTokenizing++; + this.isCheckoutLocked++; + 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.isTokenizing--; + this.isCheckoutLocked--; + this.moveToStep('paid', true); + }, 1000); + }, + error: (response) => { + this.processing = false; + this.accelerateError = response.error; + 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')}`, ``)); + }, 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.isTokenizing--; + this.isCheckoutLocked--; } }); } catch (e) { @@ -602,65 +626,84 @@ 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(); - 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 || !verificationToken.token) { - 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.token, - cardTag, - `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - costUSD, - verificationToken.userChallenged - ).subscribe({ - next: () => { + try { + // lock the checkout UI and show a loading spinner until the square modals are finished + this.isCheckoutLocked++; + this.isTokenizing++; + 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; - 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 || !verificationToken.token) { + 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()); + // keep checkout in loading state until the acceleration request completes + this.isCheckoutLocked++; + this.isTokenizing++; + this.servicesApiService.accelerateWithGooglePay$( + this.tx.txid, + tokenResult.token, + verificationToken.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + costUSD, + verificationToken.userChallenged + ).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.isTokenizing--; + this.isCheckoutLocked--; + 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(() => { + // 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.isTokenizing--; + this.isCheckoutLocked--; } }); } @@ -727,7 +770,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')}`, '')); @@ -801,7 +844,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 {