[accelerator] re-integrate square payment WIP

This commit is contained in:
nymkappa 2024-04-13 20:53:19 +09:00
parent 49b9a6f53d
commit 24e9ae6440
No known key found for this signature in database
GPG Key ID: 92358FC85D9645DE
5 changed files with 347 additions and 33 deletions

View File

@ -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: [],

View File

@ -1,42 +1,103 @@
<div class="container card" style="padding: 20px; background: var(--bg)">
<div class="row">
<div class="col-sm">
<h1 style="font-size: larger;">Accelerate your Bitcoin transaction?</h1>
@if (!showCheckoutPage) {
<!-- Show A/B CTAs -->
<div class="row mb-3">
<div class="col-sm">
<h1 style="font-size: larger;">Accelerate your Bitcoin transaction?</h1>
</div>
</div>
</div>
<form class="mt-3">
<div class="row">
<form>
<div class="row">
<div class="col-sm">
<div class="form-group form-check">
<input type="radio" class="form-check-input" id="accelerate" name="accelerate" (change)="selectedOptionChanged($event)">
<label class="form-check-label d-flex flex-column" for="accelerate">
<span class="font-weight-bold">Accelerate</span>
<span style="color: rgb(186, 186, 186)">Settlement expected in ~1 hour or less<br>
@if (!calculating) {
<app-fiat [value]="cost"></app-fiat>fee (<span><small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats|sats">sats</span></span>)
} @else {
<span class="estimating">Calculating cost...</span>
}
</span>
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group form-check">
<input type="radio" class="form-check-input" id="wait" name="accelerate" (change)="selectedOptionChanged($event)">
<label class="form-check-label d-flex flex-column" for="wait">
<span class="font-weight-bold">Wait</span>
<span style="color: rgb(186, 186, 186)">Settlement expected to occur <app-time kind="within" [time]="eta" [fastRender]="false" [fixedRender]="true"></app-time></span>
</label>
</div>
</div>
</div>
<div class="row mt-2" [style]="choosenOption === 'wait' ? 'opacity: 0.25; pointer-events: none' : ''">
<div class="col-sm d-flex flex-row justify-content-center">
<button type="button" class="mt-1 btn btn-light rounded-pill align-self-center d-flex flex-row justify-content-center align-items-center" style="width: 200px" (click)="enableCheckoutPage()">
<img src="/resources/mempool-accelerator-sparkles-compressed.svg" height="20" class="mr-2" style="margin-left: -10px">
<span>Accelerate</span>
</button>
</div>
</div>
</form>
}
@else {
<!-- Show checkout page -->
<div class="row mb-3 text-center">
<div class="col-sm">
<div class="form-group form-check">
<input type="radio" class="form-check-input" id="accelerate" name="accelerate">
<label class="form-check-label d-flex flex-column" for="accelerate">
<span class="font-weight-bold">Accelerate</span>
<span class="text-muted">Settlement expected in 1 hour or less<br>10,000 sats ($7.00) fee</span>
</label>
<h1 style="font-size: larger;">Confirm your payment</h1>
</div>
</div>
<div class="row text-center">
<div class="col-sm">
<div class="form-group w-100">
Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + txid" target="_blank">{{ txid.substr(0, 10) }}..{{ txid.substr(-10) }}</a>
</div>
</div>
</div>
<div class="row">
@if (!loadingCashapp) {
<div class="row text-center mt-1">
<div class="col-sm">
<div class="form-group w-100">
<span><u><strong>Total additional cost</strong></u><br>
<span style="font-size: 16px" class="d-block mt-2">
Pay
<strong><app-fiat [value]="cost"></app-fiat></strong>
with
</span>
</span>
</div>
</div>
</div>
}
<div class="row text-center mt-1">
<div class="col-sm">
<div class="form-group form-check">
<input type="radio" class="form-check-input" id="wait" name="accelerate">
<label class="form-check-label d-flex flex-column" for="wait">
<span class="font-weight-bold">Wait</span>
<span class="text-muted">Settlement unlikely to occur within 24 hours</span>
</label>
<div class="form-group w-100">
<div id="cash-app-pay" class="d-inline-block" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''" (click)="submitCashappPay()"></div>
@if (loadingCashapp) {
<div class="spinner-border text-light" style="width: 25px; height: 25px"></div>
}
</div>
</div>
</div>
<div class="row mt-2">
<div class="col-sm d-flex flex-row justify-content-center">
<button type="submit" class="btn btn-light w-100 rounded-pill" style="max-width: 250px">
<img src="/resources/mempool-accelerator-sparkles-compressed.svg" height="25" class="mr-2" style="margin-left: -33px">
<span>Accelerate</span>
</button>
<hr>
<div class="row mt-2 text-center">
<div class="col-sm d-flex flex-column">
<small>Changed your mind?</small>
<button type="button" class="mt-1 btn btn-secondary btn-sm rounded-pill align-self-center" style="width: 200px" (click)="restart()">Cancel</button>
</div>
</div>
</form>
}
</div>

View File

@ -0,0 +1,3 @@
.estimating {
color: var(--green)
}

View File

@ -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<void> {
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';
}
}

View File

@ -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)