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 82391dc89..b9a176055 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -389,21 +389,29 @@ } - @if (canPayWithCashapp || canPayWithApplePay) { + @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) {

OR

} } - @if (canPayWithCashapp || canPayWithApplePay) { + @if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) {

Pay  with

@if (canPayWithCashapp) { } @if (canPayWithApplePay) { - @if (canPayWithCashapp) {
} - + @if (canPayWithCashapp) { } +
+ +
+ } + @if (canPayWithGooglePay) { + @if (canPayWithCashapp || canPayWithApplePay) { } +
+ +
}
} @@ -427,7 +435,7 @@ - } @else if (step === 'cashapp' || step === 'applepay') { + } @else if (step === 'cashapp' || step === 'applepay' || step === 'googlepay') {
@@ -443,7 +451,7 @@
- @if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay) { + @if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay || step === 'googlepay' && !loadingGooglePay) {
@@ -463,11 +471,13 @@
@if (step === 'applepay') { -
+
} @else if (step === 'cashapp') { -
+
+ } @else if (step === 'googlepay') { +
} - @if (loadingCashapp || loadingApplePay) { + @if (loadingCashapp || loadingApplePay || loadingGooglePay) {
Loading payment method...
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 71c46e2da..624df2ca3 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -1,7 +1,8 @@ +/* eslint-disable no-console */ import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core'; import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs'; import { ServicesApiServices } from '../../services/services-api.service'; -import { md5, nextRoundNumber, insecureRandomUUID } from '../../shared/common.utils'; +import { md5, insecureRandomUUID } from '../../shared/common.utils'; import { StateService } from '../../services/state.service'; import { AudioService } from '../../services/audio.service'; import { ETA, EtaService } from '../../services/eta.service'; @@ -11,7 +12,7 @@ import { IAuth, AuthServiceMempool } from '../../services/auth.service'; import { EnterpriseService } from '../../services/enterprise.service'; import { ApiService } from '../../services/api.service'; -export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp'; +export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay'; export type AccelerationEstimate = { hasAccess: boolean; @@ -24,7 +25,7 @@ export type AccelerationEstimate = { mempoolBaseFee: number; vsizeFee: number; pools: number[]; - availablePaymentMethods: {[method: string]: {min: number, max: number}}; + availablePaymentMethods: Record; unavailable?: boolean; options: { // recommended bid options fee: number; // recommended userBid in sats @@ -47,7 +48,7 @@ export const MIN_BID_RATIO = 1; export const DEFAULT_BID_RATIO = 2; export const MAX_BID_RATIO = 4; -type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'processing' | 'paid' | 'success'; +type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'googlepay' | 'processing' | 'paid' | 'success'; @Component({ selector: 'app-accelerate-checkout', @@ -62,6 +63,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { @Input() scrollEvent: boolean; @Input() cashappEnabled: boolean = true; @Input() applePayEnabled: boolean = false; + @Input() googlePayEnabled: boolean = true; @Input() advancedEnabled: boolean = false; @Input() forceMobile: boolean = false; @Input() showDetails: boolean = false; @@ -83,14 +85,12 @@ export class AccelerateCheckout implements OnInit, OnDestroy { private _step: CheckoutStep = 'summary'; simpleMode: boolean = true; - paymentMethod: 'cashapp' | 'btcpay'; timeoutTimer: any; authSubscription$: Subscription; auth: IAuth | null = null; // accelerator stuff - square: { appId: string, locationId: string}; accelerationUUID: string; accelerationSubscription: Subscription; difficultySubscription: Subscription; @@ -112,14 +112,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy { // square loadingCashapp = false; loadingApplePay = false; - cashappError = false; - cashappSubmit: any; + loadingGooglePay = false; payments: any; cashAppPay: any; applePay: any; + googlePay: any; conversionsSubscription: Subscription; - conversions: any; - + conversions: Record; + // btcpay loadingBtcpayInvoice = false; invoice = undefined; @@ -137,13 +137,13 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.accelerationUUID = insecureRandomUUID(); // Check if Apple Pay available - // @ts-ignore https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview - if (window.ApplePaySession) { + // https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview + if (window['ApplePaySession']) { this.applePayEnabled = true; } } - ngOnInit() { + ngOnInit(): void { this.authSubscription$ = this.authService.getAuth$().subscribe((auth) => { if (this.auth?.user?.userId !== auth?.user?.userId) { this.auth = auth; @@ -168,13 +168,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.moveToStep('summary'); } - this.servicesApiService.setupSquare$().subscribe(ids => { - this.square = { - appId: ids.squareAppId, - locationId: ids.squareLocationId - }; - }); - this.conversionsSubscription = this.stateService.conversions$.subscribe( async (conversions) => { this.conversions = conversions; @@ -182,7 +175,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { ); } - ngOnDestroy() { + ngOnDestroy(): void { if (this.estimateSubscription) { this.estimateSubscription.unsubscribe(); } @@ -202,7 +195,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } } - moveToStep(step: CheckoutStep) { + moveToStep(step: CheckoutStep): void { this._step = step; if (this.timeoutTimer) { clearTimeout(this.timeoutTimer); @@ -228,13 +221,18 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.insertSquare(); this.setupSquare(); this.scrollToElementWithTimeout('confirm-title', 'center', 100); + } else if (this._step === 'googlepay' && this.googlePayEnabled) { + this.loadingGooglePay = true; + this.insertSquare(); + this.setupSquare(); + this.scrollToElementWithTimeout('confirm-title', 'center', 100); } else if (this._step === 'paid') { this.timePaid = Date.now(); this.timeoutTimer = setTimeout(() => { if (this.step === 'paid') { this.accelerateError = 'internal_server_error'; } - }, 120000) + }, 120000); } this.hasDetails.emit(this._step === 'quote'); } @@ -252,7 +250,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.scrollToElement(id, position); }, timeout); } - scrollToElement(id: string, position: ScrollLogicalPosition) { + scrollToElement(id: string, position: ScrollLogicalPosition): void { const acceleratePreviewAnchor = document.getElementById(id); if (acceleratePreviewAnchor) { this.cd.markForCheck(); @@ -267,7 +265,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { /** * Accelerator */ - fetchEstimate() { + fetchEstimate(): void { if (this.estimateSubscription) { this.estimateSubscription.unsubscribe(); } @@ -331,7 +329,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } }), - catchError((response) => { + catchError(() => { this.estimate = undefined; this.quoteError = `cannot_accelerate_tx`; this.estimateSubscription.unsubscribe(); @@ -402,8 +400,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { * Square */ insertSquare(): void { - //@ts-ignore - if (window.Square) { + if (window['Square']) { return; } let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js'; @@ -415,19 +412,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy { statsUrl = 'https://web.squarecdn.com/v1/square.js'; } - (function() { + (function(): void { 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 = () => { + setupSquare(): void { + const init = (): void => { this.initSquare(); }; - //@ts-ignore - if (!window.Square) { + if (!window['Square']) { console.debug('Square.js failed to load properly. Retrying in 1 second.'); setTimeout(init, 1000); } else { @@ -436,29 +431,37 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } async initSquare(): Promise { try { - //@ts-ignore - this.payments = window.Square.payments(this.square.appId, this.square.locationId) - const urlParams = new URLSearchParams(window.location.search); - if (this._step === 'cashapp' || urlParams.get('cash_request_id')) { - await this.requestCashAppPayment(); - } else if (this._step === 'applepay') { - await this.requestApplePayPayment(); - } + this.servicesApiService.setupSquare$().subscribe({ + next: async (ids) => { + this.payments = window['Square'].payments(ids.squareAppId, ids.squareLocationId); + const urlParams = new URLSearchParams(window.location.search); + if (this._step === 'cashapp' || urlParams.get('cash_request_id')) { + await this.requestCashAppPayment(); + } else if (this._step === 'applepay') { + await this.requestApplePayPayment(); + } else if (this._step === 'googlepay') { + await this.requestGooglePayPayment(); + } + }, + error: () => { + console.debug('Error loading Square Payments'); + this.accelerateError = 'cannot_setup_square'; + } + }); } catch (e) { console.debug('Error loading Square Payments', e); - this.cashappError = true; - return; + this.accelerateError = 'cannot_setup_square'; } } /** * APPLE PAY */ - async requestApplePayPayment() { + async requestApplePayPayment(): Promise { if (this.conversionsSubscription) { this.conversionsSubscription.unsubscribe(); } - + this.conversionsSubscription = this.stateService.conversions$.subscribe( async (conversions) => { this.conversions = conversions; @@ -505,6 +508,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.accelerationUUID ).subscribe({ next: () => { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); if (this.applePay) { this.applePay.destroy(); @@ -542,13 +546,100 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } /** - * CASHAPP + * GOOGLE PAY */ - async requestCashAppPayment() { + async requestGooglePayPayment(): Promise { if (this.conversionsSubscription) { this.conversionsSubscription.unsubscribe(); } - + + this.conversionsSubscription = this.stateService.conversions$.subscribe( + async (conversions) => { + this.conversions = conversions; + if (this.googlePay) { + this.googlePay.destroy(); + } + + const costUSD = this.cost / 100_000_000 * conversions.USD; + const paymentRequest = this.payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { + amount: costUSD.toFixed(2), + label: 'Total' + } + }); + this.googlePay = await this.payments.googlePay(paymentRequest , { + referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + }); + + await this.googlePay.attach(`#google-pay-button`, { + buttonType: 'pay', + buttonSizeMode: 'fill', + }); + this.loadingGooglePay = false; + + document.getElementById('google-pay-button').addEventListener('click', async event => { + 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'; + return; + } + const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); + this.servicesApiService.accelerateWithGooglePay$( + this.tx.txid, + tokenResult.token, + cardTag, + `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, + this.accelerationUUID + ).subscribe({ + next: () => { + 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.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); + } + } + }); + } else { + let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors, + )}`; + } + throw new Error(errorMessage); + } + }); + } + ); + } + + /** + * CASHAPP + */ + async requestCashAppPayment(): Promise { + if (this.conversionsSubscription) { + this.conversionsSubscription.unsubscribe(); + } + this.conversionsSubscription = this.stateService.conversions$.subscribe( async (conversions) => { this.conversions = conversions; @@ -566,18 +657,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy { label: 'Total', pending: true, productUrl: `${redirectHostname}/tracker/${this.tx.txid}`, - }, - button: { shape: 'semiround', size: 'small', theme: 'light'} + } }); this.cashAppPay = await this.payments.cashAppPay(paymentRequest, { redirectURL: `${redirectHostname}/tracker/${this.tx.txid}`, - referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`, - button: { shape: 'semiround', size: 'small', theme: 'light'} + referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}` }); - if (this.step === 'cashapp') { - await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'light', size: 'small', shape: 'semiround' }) - } + await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'dark' }); this.loadingCashapp = false; this.cashAppPay.addEventListener('ontokenization', event => { @@ -626,7 +713,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { /** * BTCPay */ - async requestBTCPayInvoice() { + async requestBTCPayInvoice(): Promise { this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe( switchMap(response => { return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId); @@ -656,53 +743,60 @@ export class AccelerateCheckout implements OnInit, OnDestroy { /** * UI events */ - selectedOptionChanged(event) { + selectedOptionChanged(event): void { this.selectedOption = event.target.id; } - get step() { + get step(): CheckoutStep { return this._step; } - get paymentMethods() { - return Object.keys(this.estimate?.availablePaymentMethods || {}); + get paymentMethods(): PaymentMethod[] { + return Object.keys(this.estimate?.availablePaymentMethods || {}) as PaymentMethod[]; } - get couldPayWithBitcoin() { + get couldPayWithBitcoin(): boolean { return !!this.estimate?.availablePaymentMethods?.bitcoin; } - get couldPayWithCashapp() { + get couldPayWithCashapp(): boolean { if (!this.cashappEnabled) { return false; } return !!this.estimate?.availablePaymentMethods?.cashapp; } - get couldPayWithApplePay() { + get couldPayWithApplePay(): boolean { if (!this.applePayEnabled) { return false; } return !!this.estimate?.availablePaymentMethods?.applePay; } - get couldPayWithBalance() { + get couldPayWithGooglePay(): boolean { + if (!this.googlePayEnabled) { + return false; + } + return !!this.estimate?.availablePaymentMethods?.googlePay; + } + + get couldPayWithBalance(): boolean { if (!this.hasAccessToBalanceMode) { return false; } return !!this.estimate?.availablePaymentMethods?.balance; } - get couldPay() { - return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp || this.couldPayWithApplePay; + get couldPay(): boolean { + return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp || this.couldPayWithApplePay || this.couldPayWithGooglePay; } - get canPayWithBitcoin() { + get canPayWithBitcoin(): boolean { const paymentMethod = this.estimate?.availablePaymentMethods?.bitcoin; return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max; } - get canPayWithCashapp() { + get canPayWithCashapp(): boolean { if (!this.cashappEnabled || !this.conversions) { return false; } @@ -718,7 +812,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { return false; } - get canPayWithApplePay() { + get canPayWithApplePay(): boolean { if (!this.applePayEnabled || !this.conversions) { return false; } @@ -734,7 +828,23 @@ export class AccelerateCheckout implements OnInit, OnDestroy { return false; } - get canPayWithBalance() { + get canPayWithGooglePay(): boolean { + if (!this.googlePayEnabled || !this.conversions) { + return false; + } + + const paymentMethod = this.estimate?.availablePaymentMethods?.googlePay; + if (paymentMethod) { + const costUSD = (this.cost / 100_000_000 * this.conversions.USD); + if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) { + return true; + } + } + + return false; + } + + get canPayWithBalance(): boolean { if (!this.hasAccessToBalanceMode) { return false; } @@ -742,11 +852,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy { return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max && this.cost <= this.estimate?.userBalance; } - get canPay() { - return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp || this.canPayWithApplePay; + get canPay(): boolean { + return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp || this.canPayWithApplePay || this.canPayWithGooglePay; } - get hasAccessToBalanceMode() { + get hasAccessToBalanceMode(): boolean { return this.isLoggedIn() && this.estimate?.hasAccess; } diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 22789986e..35e0ffa09 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -7,7 +7,7 @@ export interface WebsocketResponse { backend?: 'esplora' | 'electrum' | 'none'; block?: BlockExtended; blocks?: BlockExtended[]; - conversions?: any; + conversions?: Record; txConfirmed?: string; historicalDate?: string; mempoolInfo?: MempoolInfo; diff --git a/frontend/src/app/services/services-api.service.ts b/frontend/src/app/services/services-api.service.ts index d12fc959f..1366342f7 100644 --- a/frontend/src/app/services/services-api.service.ts +++ b/frontend/src/app/services/services-api.service.ts @@ -144,6 +144,10 @@ export class ServicesApiServices { return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID }); } + accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string) { + return this.httpClient.post(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID }); + } + getAccelerations$(): Observable { return this.httpClient.get(`${this.stateService.env.SERVICES_API}/accelerator/accelerations`); } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 05f1ac69f..8928245a7 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -138,7 +138,7 @@ export class StateService { blocksSubject$ = new BehaviorSubject([]); blocks$: Observable; transactions$ = new BehaviorSubject(null); - conversions$ = new ReplaySubject(1); + conversions$ = new ReplaySubject>(1); bsqPrice$ = new ReplaySubject(1); mempoolInfo$ = new ReplaySubject(1); mempoolBlocks$ = new ReplaySubject(1); diff --git a/frontend/src/resources/apple-pay.png b/frontend/src/resources/apple-pay.png new file mode 100644 index 000000000..3c446c698 Binary files /dev/null and b/frontend/src/resources/apple-pay.png differ diff --git a/frontend/src/resources/apple-pay.svg b/frontend/src/resources/apple-pay.svg deleted file mode 100755 index 0c6ecafef..000000000 --- a/frontend/src/resources/apple-pay.svg +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/resources/google-pay.png b/frontend/src/resources/google-pay.png new file mode 100644 index 000000000..a44046d37 Binary files /dev/null and b/frontend/src/resources/google-pay.png differ