diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index c4c065615..7a23e7556 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -8,6 +8,7 @@ import { ClockComponent } from './components/clock/clock.component'; import { StatusViewComponent } from './components/status-view/status-view.component'; import { AddressGroupComponent } from './components/address-group/address-group.component'; import { TrackerComponent } from './components/tracker/tracker.component'; +import { AccelerateCheckout } from './components/accelerate-checkout/accelerate-checkout.component'; const browserWindow = window || {}; // @ts-ignore 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 d6baf755d..5a73a48cf 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -1,47 +1,147 @@
-
-
-

Accelerate your Bitcoin transaction?

-
-
+ @if (error) { + + } @else { -
-
-
-
- - + @if (step === 'completed') { +
+
+
+
+
Transaction is now being accelerated!
+
+
-
-
-
-
- -
-
-
- -
-
- - -
\ No newline at end of file +
+
+
+ Changed your mind? + +
+
+ } + + @else if (step === 'processing') { +
+
+

Confirm your payment

+
+
+ + +
+ +
+
+
+
+ We are processing your payment... +
+
+
+
+
+ } + + + } + +
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 617147221..315bdbbd2 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss @@ -2,4 +2,8 @@ position: absolute; top: 0.5em; right: 0.5em; -} \ No newline at end of file +} + +.estimating { + color: var(--green) +} 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 2de1aa34b..622256461 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -1,6 +1,10 @@ -import { Component, OnInit, OnDestroy, Output, EventEmitter, Input } from '@angular/core'; -import { Transaction } from '../../interfaces/electrs.interface'; -import { MempoolPosition } from '../../interfaces/node-api.interface'; +import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef } from '@angular/core'; +import { Subscription, tap, of, catchError } from 'rxjs'; +import { WebsocketService } from '../../services/websocket.service'; +import { ServicesApiServices } from '../../services/services-api.service'; +import { nextRoundNumber } from '../../shared/common.utils'; +import { StateService } from '../../services/state.service'; +import { AudioService } from '../../services/audio.service'; @Component({ selector: 'app-accelerate-checkout', @@ -8,21 +12,243 @@ import { MempoolPosition } from '../../interfaces/node-api.interface'; styleUrls: ['./accelerate-checkout.component.scss'] }) export class AccelerateCheckout implements OnInit, OnDestroy { - @Input() tx: Transaction ; - @Input() eta: number; + @Input() eta: number = Date.now() + 123456789; + @Input() txid: string = '70c18d76cdb285a1b5bd87fdaae165880afa189809c30b4083ff7c0e69ee09ad'; @Output() close = new EventEmitter(); - constructor() { + calculating = true; + choosenOption: 'wait' | 'accelerate' = 'wait'; + error = ''; + + // accelerator stuff + square: { appId: string, locationId: string}; + accelerationUUID: string; + estimateSubscription: Subscription; + maxBidBoost: number; // sats + cost: number; // sats + + // square + loadingCashapp = false; + cashappSubmit: any; + payments: any; + cashAppPay: any; + cashAppSubscription: Subscription; + conversionsSubscription: Subscription; + step: 'cta' | 'checkout' | 'processing' | 'completed' = 'completed'; + + constructor( + private websocketService: WebsocketService, + private servicesApiService: ServicesApiServices, + private stateService: StateService, + private audioService: AudioService, + private cd: ChangeDetectorRef + ) { + this.accelerationUUID = window.crypto.randomUUID(); } ngOnInit() { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('cash_request_id')) { // Redirected from cashapp + this.insertSquare(); + this.setupSquare(); + this.step = 'processing'; + } + + this.servicesApiService.setupSquare$().subscribe(ids => { + this.square = { + appId: ids.squareAppId, + locationId: ids.squareLocationId + }; + if (this.step === 'cta') { + this.estimate(); + } + }); + } ngOnDestroy() { + if (this.estimateSubscription) { + this.estimateSubscription.unsubscribe(); + } } + /** + * Accelerator + */ + estimate() { + if (this.estimateSubscription) { + this.estimateSubscription.unsubscribe(); + } + this.calculating = true; + this.estimateSubscription = this.servicesApiService.estimate$(this.txid).pipe( + tap((response) => { + this.calculating = false; + if (response.status === 204) { + this.error = `cannot_accelerate_tx`; + } else { + const estimation = response.body; + if (!estimation) { + this.error = `cannot_accelerate_tx`; + return; + } + // Make min extra fee at least 50% of the current tx fee + const minExtraBoost = nextRoundNumber(Math.max(estimation.cost * 2, estimation.txSummary.effectiveFee)); + const DEFAULT_BID_RATIO = 2; + this.maxBidBoost = minExtraBoost * DEFAULT_BID_RATIO; + this.cost = this.maxBidBoost * DEFAULT_BID_RATIO + estimation.mempoolBaseFee + estimation.vsizeFee; + } + }), + + catchError((response) => { + this.error = `cannot_accelerate_tx`; + return of(null); + }) + ).subscribe(); + } + + /** + * Square + */ + insertSquare(): void { + //@ts-ignore + if (window.Square) { + return; + } + let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js'; + if (document.location.hostname === 'mempool-staging.fmt.mempool.space' || + document.location.hostname === 'mempool-staging.va1.mempool.space' || + document.location.hostname === 'mempool-staging.fra.mempool.space' || + document.location.hostname === 'mempool-staging.tk7.mempool.space' || + document.location.hostname === 'mempool.space') { + statsUrl = 'https://web.squarecdn.com/v1/square.js'; + } + + (function() { + const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + // @ts-ignore + g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s); + })(); + } + setupSquare() { + const init = () => { + this.initSquare(); + }; + + //@ts-ignore + if (!window.Square) { + console.debug('Square.js failed to load properly. Retrying in 1 second.'); + setTimeout(init, 1000); + } else { + init(); + } + } + async initSquare(): Promise { + try { + //@ts-ignore + this.payments = window.Square.payments(this.square.appId, this.square.locationId) + await this.requestCashAppPayment(); + } catch (e) { + console.error(e); + this.error = 'Error loading Square Payments'; + return; + } + } + async requestCashAppPayment() { + if (this.cashAppSubscription) { + this.cashAppSubscription.unsubscribe(); + } + if (this.conversionsSubscription) { + this.conversionsSubscription.unsubscribe(); + } + + this.conversionsSubscription = this.stateService.conversions$.subscribe( + async (conversions) => { + if (this.cashAppPay) { + await this.cashAppPay.destroy(); + } + + const redirectHostname = document.location.hostname === 'localhost' ? `http://localhost:4200`: `https://${document.location.hostname}`; + const costUSD = this.step === 'processing' ? 69.69 : (this.cost / 100_000_000 * conversions.USD); // When we're redirected to this component, the payment data is already linked to the payment token, so does not matter what amonut we put in there, therefore it's 69.69 + const paymentRequest = this.payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { + amount: costUSD.toString(), + label: 'Total', + pending: true, + productUrl: `${redirectHostname}/tracker/${this.txid}`, + }, + button: { shape: 'semiround', size: 'small', theme: 'light'} + }); + this.cashAppPay = await this.payments.cashAppPay(paymentRequest, { + redirectURL: `${redirectHostname}/tracker/${this.txid}`, + referenceId: `accelerator-${this.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + button: { shape: 'semiround', size: 'small', theme: 'light'} + }); + + if (this.step === 'checkout') { + await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'light', size: 'small', shape: 'semiround' }) + } + this.loadingCashapp = false; + + const that = this; + this.cashAppPay.addEventListener('ontokenization', function (event) { + const { tokenResult, error } = event.detail; + if (error) { + this.error = error; + } else if (tokenResult.status === 'OK') { + that.servicesApiService.accelerateWithCashApp$( + that.txid, + tokenResult.token, + tokenResult.details.cashAppPay.cashtag, + tokenResult.details.cashAppPay.referenceId, + that.accelerationUUID + ).subscribe({ + next: () => { + that.audioService.playSound('ascend-chime-cartoon'); + that.step = 'completed'; + setTimeout(() => { + that.closeModal(); + }, 10000); + }, + error: (response) => { + if (response.status === 403 && response.error === 'not_available') { + that.error = 'waitlisted'; + } else { + that.error = response.error; + } + } + }); + } + }); + } + ); + } + + /** + * UI events + */ + enableCheckoutPage() { + this.step = 'checkout'; + this.loadingCashapp = true; + this.insertSquare(); + this.setupSquare(); + } + selectedOptionChanged(event) { + this.choosenOption = event.target.id; + if (this.choosenOption === 'wait') { + this.restart(); + this.closeModal(); + } + } + restart() { + this.step = 'cta'; + this.choosenOption = 'wait'; + } closeModal(): void { - console.log('close modal') + if (this.cashAppPay) { + this.cashAppPay.destroy(); + } this.close.emit(); } } diff --git a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts index ca4e61c06..ec36107a4 100644 --- a/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts +++ b/frontend/src/app/components/accelerate-preview/accelerate-preview.component.ts @@ -362,7 +362,6 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges that.accelerationSubscription = that.servicesApiService.accelerateWithCashApp$( that.tx.txid, - that.userBid, tokenResult.token, tokenResult.details.cashAppPay.cashtag, tokenResult.details.cashAppPay.referenceId, diff --git a/frontend/src/app/components/time/time.component.ts b/frontend/src/app/components/time/time.component.ts index 45070ad67..6ed3d8cf6 100644 --- a/frontend/src/app/components/time/time.component.ts +++ b/frontend/src/app/components/time/time.component.ts @@ -23,7 +23,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { @Input() time: number; @Input() dateString: number; - @Input() kind: 'plain' | 'since' | 'until' | 'span' | 'before' = 'plain'; + @Input() kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within' = 'plain'; @Input() fastRender = false; @Input() fixedRender = false; @Input() relative = false; @@ -80,6 +80,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { seconds = Math.floor((+new Date() - +new Date(this.dateString || this.time * 1000)) / 1000); break; case 'until': + case 'within': seconds = (+new Date(this.time) - +new Date()) / 1000; break; default: @@ -91,7 +92,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { } else if (seconds < 60) { if (this.relative || this.kind === 'since') { return $localize`:@@date-base.just-now:Just now`; - } else if (this.kind === 'until') { + } else if (this.kind === 'until' || this.kind === 'within') { seconds = 60; } } @@ -112,12 +113,12 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { if (counter > 0) { let rounded; const roundFactor = Math.pow(10,this.fractionDigits || 0); - if (this.kind === 'until' && usedUnits < this.numUnits) { + if ((this.kind === 'until' || this.kind === 'within') && usedUnits < this.numUnits) { rounded = Math.floor((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor; } else { rounded = Math.round((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor; } - if (this.kind !== 'until' || this.numUnits === 1) { + if ((this.kind !== 'until' && this.kind !== 'within')|| this.numUnits === 1) { return this.formatTime(this.kind, precisionUnit, rounded); } else { if (!usedUnits) { @@ -185,6 +186,29 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { } } break; + case 'within': + if (number === 1) { + switch (unit) { // singular (In ~1 day) + case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYear}:DATE:`; break; + case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonth}:DATE:`; break; + case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeek}:DATE:`; break; + case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDay}:DATE:`; break; + case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHour}:DATE:`; break; + case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinute}:DATE:`; + case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSecond}:DATE:`; + } + } else { + switch (unit) { // plural (In ~2 days) + case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYears}:DATE:`; break; + case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonths}:DATE:`; break; + case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeeks}:DATE:`; break; + case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDays}:DATE:`; break; + case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHours}:DATE:`; break; + case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinutes}:DATE:`; break; + case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSeconds}:DATE:`; break; + } + } + break; case 'span': if (number === 1) { switch (unit) { // singular (1 day) diff --git a/frontend/src/app/components/tracker/tracker.component.html b/frontend/src/app/components/tracker/tracker.component.html index 2d3d50d76..d83b7fd84 100644 --- a/frontend/src/app/components/tracker/tracker.component.html +++ b/frontend/src/app/components/tracker/tracker.component.html @@ -42,7 +42,7 @@ } @if (isMobile && paymentType === 'cashapp' && accelerationEligible && !tx.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !tx?.acceleration) { - Accelerate + Accelerate }
@@ -78,7 +78,7 @@
@if (showAccelerationSummary) { - + } @else { @if (tx?.acceleration && !tx.status?.confirmed) {
diff --git a/frontend/src/app/components/tracker/tracker.component.ts b/frontend/src/app/components/tracker/tracker.component.ts index 2774dfd76..eb2a9b302 100644 --- a/frontend/src/app/components/tracker/tracker.component.ts +++ b/frontend/src/app/components/tracker/tracker.component.ts @@ -146,6 +146,11 @@ export class TrackerComponent implements OnInit, OnDestroy { if (this.acceleratorAvailable && this.stateService.ref === 'https://cash.app/') { this.paymentType = 'cashapp'; } + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('cash_request_id')) { + this.showAccelerationSummary = true; + } + this.showAccelerationSummary = true; this.enterpriseService.page(); diff --git a/frontend/src/app/services/services-api.service.ts b/frontend/src/app/services/services-api.service.ts index 44d253efa..89ea9a603 100644 --- a/frontend/src/app/services/services-api.service.ts +++ b/frontend/src/app/services/services-api.service.ts @@ -132,8 +132,8 @@ export class ServicesApiServices { return this.httpClient.post(`${SERVICES_API_PREFIX}/accelerator/accelerate`, { txInput: txInput, userBid: userBid, accelerationUUID: accelerationUUID }); } - accelerateWithCashApp$(txInput: string, userBid: number, token: string, cashtag: string, referenceId: string, accelerationUUID: string) { - return this.httpClient.post(`${SERVICES_API_PREFIX}/accelerator/accelerate/cashapp`, { txInput: txInput, userBid: userBid, token: token, cashtag: cashtag, referenceId: referenceId, accelerationUUID: accelerationUUID }); + accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: string, accelerationUUID: string) { + return this.httpClient.post(`${SERVICES_API_PREFIX}/accelerator/accelerate/cashapp`, { txInput: txInput, token: token, cashtag: cashtag, referenceId: referenceId, accelerationUUID: accelerationUUID }); } getAccelerations$(): Observable {