diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 7ec9a37d3..51509309e 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -7,6 +7,7 @@ import { MempoolBlockViewComponent } from './components/mempool-block-view/mempo 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 { AccelerateCheckout } from './components/accelerate-checkout/accelerate-checkout.component'; const browserWindow = window || {}; // @ts-ignore @@ -105,6 +106,14 @@ let routes: Routes = [ loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), data: { preload: true }, }, + { + path: 'accelerate-checkout', + children: [], + component: AccelerateCheckout, + data: { + networkSpecific: true, + } + }, { path: 'wallet', children: [], 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 6010fe47f..f19419aa8 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -1,42 +1,103 @@
-
-
-

Accelerate your Bitcoin transaction?

+ @if (!showCheckoutPage) { + +
+
+

Accelerate your Bitcoin transaction?

+
-
-
-
+ +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+ +
+
+ + } + + @else { + +
-
- - +

Confirm your payment

+
+
+ +
+
+
+ Payment to mempool.space for acceleration of txid {{ txid.substr(0, 10) }}..{{ txid.substr(-10) }}
-
+ + @if (!loadingCashapp) { +
+
+
+ Total additional cost
+ + Pay + + with + +
+
+
+
+ } + +
-
- - +
+
+ @if (loadingCashapp) { +
+ }
-
-
- + +
+
+
+ Changed your mind? +
- + }
\ No newline at end of file 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 e69de29bb..395b14d25 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.scss @@ -0,0 +1,3 @@ +.estimating { + color: var(--green) +} \ No newline at end of file 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 0591e58a2..1576c173b 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -1,4 +1,9 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, Input } 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'; @Component({ selector: 'app-accelerate-checkout', @@ -6,12 +11,224 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; styleUrls: ['./accelerate-checkout.component.scss'] }) export class AccelerateCheckout implements OnInit, OnDestroy { - constructor() { - } + @Input() eta: number = Date.now() + 123456789; + @Input() txid: string = '70c18d76cdb285a1b5bd87fdaae165880afa189809c30b4083ff7c0e69ee09ad'; + + calculating = true; + choosenOption: 'wait' | 'accelerate' = 'wait'; + showCheckoutPage = false; + error = ''; + + // accelerator stuff + square: { appId: string, locationId: string}; + accelerationUUID: string; + estimateSubscription: Subscription; + cost: number; // sats + + // square + cashappSubmit: any; + payments: any; + cashAppPay: any; + cashAppSubscription: Subscription; + conversionsSubscription: Subscription; + loadingCashapp = true; + processingPayment = true; + + constructor( + private websocketService: WebsocketService, + private servicesApiService: ServicesApiServices, + private stateService: StateService + ) {} ngOnInit() { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('cash_request_id')) { // Redirected from cashapp + this.processingPayment = true; + window.scrollTo(0, 0); + } else { + this.servicesApiService.setupSquare$().subscribe(ids => { + this.square = { + appId: ids.squareAppId, + locationId: ids.squareLocationId + }; + 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 minExtraCost = nextRoundNumber(Math.max(estimation.cost * 2, estimation.txSummary.effectiveFee)); + const DEFAULT_BID_RATIO = 2; + this.cost = minExtraCost * 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() { + this.loadingCashapp = true; + + if (this.cashAppSubscription) { + this.cashAppSubscription.unsubscribe(); + } + if (this.conversionsSubscription) { + this.conversionsSubscription.unsubscribe(); + } + + this.conversionsSubscription = this.stateService.conversions$.subscribe( + async (conversions) => { + if (this.cashAppPay) { + this.cashAppPay.destroy(); + } + + const redirectHostname = document.location.hostname === 'localhost' ? `http://localhost:4200`: `https://${document.location.hostname}`; + const costUSD = this.cost / 100_000_000 * conversions.USD; + 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}?acceleration=false`, + referenceId: `accelerator-${this.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + button: { shape: 'semiround', size: 'small', theme: 'light'} + }); + this.cashappSubmit = await this.cashAppPay.CashAppPayInstance.render('#cash-app-pay', { button: { theme: 'light', size: 'small', shape: 'semiround' }, manage: 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, + that.cost, + tokenResult.token, + tokenResult.details.cashAppPay.cashtag, + tokenResult.details.cashAppPay.referenceId, + that.accelerationUUID + ).subscribe({ + next: () => { + that.estimateSubscription.unsubscribe(); + }, + error: (response) => { + if (response.status === 403 && response.error === 'not_available') { + that.error = 'waitlisted'; + } else { + that.error = response.error; + } + } + }); + } + }); + this.loadingCashapp = false; + } + ); + } + submitCashappPay(): void { + if (this.cashappSubmit) { + this.cashappSubmit?.begin(); + this.processingPayment = true; + } + } + + /** + * UI events + */ + enableCheckoutPage() { + this.showCheckoutPage = true; + this.insertSquare(); + this.setupSquare(); + } + selectedOptionChanged(event) { + this.choosenOption = event.target.id; + } + restart() { + this.showCheckoutPage = false + this.choosenOption = 'wait'; } } 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)