Merge pull request #5704 from mempool/mononaut/sca-loading-ux

[accelerator] improve SCA UX
This commit is contained in:
wiz 2025-01-17 17:04:52 +09:00 committed by GitHub
commit 8793fafa4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 167 additions and 112 deletions

View File

@ -1,4 +1,4 @@
<div class="box card w-100" style="background: var(--box-bg)" id=acceleratePreviewAnchor> <div class="box card w-100 accelerate-checkout-inner" [class.input-disabled]="isCheckoutLocked > 0" style="background: var(--box-bg)" id=acceleratePreviewAnchor>
@if (accelerateError) { @if (accelerateError) {
<div class="row mb-1 text-center"> <div class="row mb-1 text-center">
<div class="col-sm"> <div class="col-sm">
@ -361,7 +361,7 @@
<div class="row text-center justify-content-center mx-2"> <div class="row text-center justify-content-center mx-2">
<p i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></p> <p i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></p>
</div> </div>
@if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp)) { @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {
<div class="row"> <div class="row">
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center"> <div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container>&nbsp;<small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></p> <p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container>&nbsp;<small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></p>
@ -484,6 +484,11 @@
</div> </div>
} }
</div> </div>
@if (isTokenizing > 0) {
<div class="d-flex flex-row justify-content-center">
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
</div>
}
</div> </div>
</div> </div>

View File

@ -8,6 +8,13 @@
color: var(--green) color: var(--green)
} }
.accelerate-checkout-inner {
&.input-disabled {
pointer-events: none;
opacity: 0.75;
}
}
.paymentMethod { .paymentMethod {
padding: 10px; padding: 10px;
background-color: var(--secondary); background-color: var(--secondary);

View File

@ -76,6 +76,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
calculating = true; calculating = true;
processing = false; processing = false;
isCheckoutLocked = 0; // reference counter, 0 = unlocked, >0 = locked
isTokenizing = 0; // reference counter, 0 = false, >0 = true
selectedOption: 'wait' | 'accel'; selectedOption: 'wait' | 'accel';
cantPayReason = ''; cantPayReason = '';
quoteError = ''; // error fetching estimate or initial data quoteError = ''; // error fetching estimate or initial data
@ -154,7 +156,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerateError = null; this.accelerateError = null;
this.timePaid = 0; this.timePaid = 0;
this.btcpayInvoiceFailed = false; this.btcpayInvoiceFailed = false;
this.moveToStep('summary'); this.moveToStep('summary', true);
} else { } else {
this.auth = auth; this.auth = auth;
} }
@ -163,11 +165,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('cash_request_id')) { // Redirected from cashapp if (urlParams.get('cash_request_id')) { // Redirected from cashapp
this.moveToStep('processing'); this.moveToStep('processing', true);
this.insertSquare(); this.insertSquare();
this.setupSquare(); this.setupSquare();
} else { } else {
this.moveToStep('summary'); this.moveToStep('summary', true);
} }
this.conversionsSubscription = this.stateService.conversions$.subscribe( this.conversionsSubscription = this.stateService.conversions$.subscribe(
@ -192,14 +194,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} }
if (changes.accelerating && this.accelerating) { if (changes.accelerating && this.accelerating) {
if (this.step === 'processing' || this.step === 'paid') { 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 } else { // Edge case where the transaction gets accelerated by someone else or on another session
this.closeModal(); this.closeModal();
} }
} }
} }
moveToStep(step: CheckoutStep): void { moveToStep(step: CheckoutStep, force: boolean = false): void {
if (this.isCheckoutLocked > 0 && !force) {
return;
}
this.processing = false; this.processing = false;
this._step = step; this._step = step;
if (this.timeoutTimer) { if (this.timeoutTimer) {
@ -242,7 +247,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
closeModal(): void { closeModal(): void {
this.completed.emit(true); 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.audioService.playSound('ascend-chime-cartoon');
this.showSuccess = true; this.showSuccess = true;
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
this.moveToStep('paid'); this.moveToStep('paid', true);
}, },
error: (response) => { error: (response) => {
this.processing = false; this.processing = false;
@ -503,56 +508,75 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} }
this.loadingApplePay = false; this.loadingApplePay = false;
applePayButton.addEventListener('click', async event => { applePayButton.addEventListener('click', async event => {
if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
return;
}
event.preventDefault(); event.preventDefault();
const tokenResult = await this.applePay.tokenize(); try {
if (tokenResult?.status === 'OK') { // lock the checkout UI and show a loading spinner until the square modals are finished
const card = tokenResult.details?.card; this.isCheckoutLocked++;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { this.isTokenizing++;
console.error(`Cannot retreive payment card details`); const tokenResult = await this.applePay.tokenize();
this.accelerateError = 'apple_pay_no_card_details'; if (tokenResult?.status === 'OK') {
this.processing = false; const card = tokenResult.details?.card;
return; if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
} console.error(`Cannot retreive payment card details`);
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); this.accelerateError = 'apple_pay_no_card_details';
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.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); return;
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);
}
} }
}); const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
} else { // keep checkout in loading state until the acceleration request completes
this.processing = false; this.isTokenizing++;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; this.isCheckoutLocked++;
if (tokenResult.errors) { this.servicesApiService.accelerateWithApplePay$(
errorMessage += ` and errors: ${JSON.stringify( this.tx.txid,
tokenResult.errors, 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) { } catch (e) {
@ -602,65 +626,84 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.loadingGooglePay = false; this.loadingGooglePay = false;
document.getElementById('google-pay-button').addEventListener('click', async event => { document.getElementById('google-pay-button').addEventListener('click', async event => {
if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
return;
}
event.preventDefault(); event.preventDefault();
const tokenResult = await this.googlePay.tokenize(); try {
if (tokenResult?.status === 'OK') { // lock the checkout UI and show a loading spinner until the square modals are finished
const card = tokenResult.details?.card; this.isCheckoutLocked++;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { this.isTokenizing++;
console.error(`Cannot retreive payment card details`); const tokenResult = await this.googlePay.tokenize();
this.accelerateError = 'apple_pay_no_card_details'; if (tokenResult?.status === 'OK') {
this.processing = false; const card = tokenResult.details?.card;
return; if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
} console.error(`Cannot retreive payment card details`);
const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2)); this.accelerateError = 'apple_pay_no_card_details';
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: () => {
this.processing = false; this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); return;
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);
}
} }
}); const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2));
} else { if (!verificationToken || !verificationToken.token) {
this.processing = false; console.error(`SCA verification failed`);
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; this.accelerateError = 'SCA Verification Failed. Payment Declined.';
if (tokenResult.errors) { this.processing = false;
errorMessage += ` and errors: ${JSON.stringify( return;
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.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(); this.cashAppPay.destroy();
} }
setTimeout(() => { setTimeout(() => {
this.moveToStep('paid'); this.moveToStep('paid', true);
if (window.history.replaceState) { if (window.history.replaceState) {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, '')); 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.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon'); this.audioService.playSound('ascend-chime-cartoon');
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
this.moveToStep('paid'); this.moveToStep('paid', true);
} }
isLoggedIn(): boolean { isLoggedIn(): boolean {