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 261dde8f5..4dff9ea38 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -10,9 +10,9 @@ import { Transaction } from '@interfaces/electrs.interface'; import { MiningStats } from '@app/services/mining.service'; import { IAuth, AuthServiceMempool } from '@app/services/auth.service'; import { EnterpriseService } from '@app/services/enterprise.service'; +import { PartnerCodeService } from '@app/services/partner-code.service'; import { ApiService } from '@app/services/api.service'; import { isDevMode } from '@angular/core'; -import { StorageService } from '@app/services/storage.service'; import { ThemeService } from '../../services/theme.service'; export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay' | 'cardOnFile'; @@ -146,7 +146,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { private cd: ChangeDetectorRef, private authService: AuthServiceMempool, private enterpriseService: EnterpriseService, - private storageService: StorageService, + private partnerCodeService: PartnerCodeService, private themeService: ThemeService, ) { this.isProdDomain = this.stateService.isProdDomain; @@ -562,8 +562,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.partnerCode ).subscribe({ next: (response) => { - this.storageService.removeItem('partnerCode'); // Consume localStorage partnerCode - this.partnerCode = undefined; + this.partnerCodeService.clearStoredPartnerCode(); this.accelerationResponse = response; this.processing = false; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); @@ -687,8 +686,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.partnerCode ).subscribe({ next: (response) => { - this.storageService.removeItem('partnerCode'); // Consume localStorage partnerCode - this.partnerCode = undefined; + this.partnerCodeService.clearStoredPartnerCode(); this.accelerationResponse = response; this.processing = false; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); @@ -792,8 +790,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.partnerCode ).subscribe({ next: (response) => { - this.storageService.removeItem('partnerCode'); // Consume localStorage partnerCode - this.partnerCode = undefined; + this.partnerCodeService.clearStoredPartnerCode(); this.accelerationResponse = response; this.processing = false; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); @@ -883,8 +880,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.partnerCode ).subscribe({ next: (response) => { - this.storageService.removeItem('partnerCode'); // Consume localStorage partnerCode - this.partnerCode = undefined; + this.partnerCodeService.clearStoredPartnerCode(); this.accelerationResponse = response; this.processing = false; this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); @@ -966,8 +962,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } bitcoinPaymentCompleted(): void { - this.storageService.removeItem('partnerCode'); // Consume localStorage partnerCode - this.partnerCode = undefined; + this.partnerCodeService.clearStoredPartnerCode(); this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); this.estimateSubscription.unsubscribe(); diff --git a/frontend/src/app/components/tracker/tracker.component.ts b/frontend/src/app/components/tracker/tracker.component.ts index 616f174f0..7ad2817a7 100644 --- a/frontend/src/app/components/tracker/tracker.component.ts +++ b/frontend/src/app/components/tracker/tracker.component.ts @@ -27,12 +27,12 @@ import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment import { PriceService } from '@app/services/price.service'; import { ServicesApiServices } from '@app/services/services-api.service'; import { EnterpriseService } from '@app/services/enterprise.service'; +import { PartnerCodeService } from '@app/services/partner-code.service'; import { ZONE_SERVICE } from '@app/injection-tokens'; import { TrackerStage } from '@components/tracker/tracker-bar.component'; import { MiningService, MiningStats } from '@app/services/mining.service'; import { ETA, EtaService } from '@app/services/eta.service'; import { getTransactionFlags, getUnacceleratedFeeRate } from '@app/shared/transaction.utils'; -import { StorageService } from '@app/services/storage.service'; interface Pool { @@ -84,6 +84,8 @@ export class TrackerComponent implements OnInit, OnDestroy { blocksSubscription: Subscription; miningSubscription: Subscription; currencyChangeSubscription: Subscription; + urlFragmentSubscription: Subscription; + partnerCodeSubscription: Subscription; rbfTransaction: undefined | Transaction; replaced: boolean = false; latestReplacement: string; @@ -144,25 +146,20 @@ export class TrackerComponent implements OnInit, OnDestroy { private seoService: SeoService, private priceService: PriceService, private enterpriseService: EnterpriseService, + private partnerCodeService: PartnerCodeService, private miningService: MiningService, private router: Router, private cd: ChangeDetectorRef, private zone: NgZone, - private storageService: StorageService, @Inject(ZONE_SERVICE) private zoneService: any, ) {} ngOnInit() { this.onResize(); - - // Accelerator partner code in fragment - const fragmentsParams = new URLSearchParams(window.location.hash.slice(1)); - this.partnerCode = fragmentsParams.get('partnerCode') ?? this.storageService.getValue('partnerCode') ?? undefined; - if (this.partnerCode) { - this.storageService.setValue('partnerCode', this.partnerCode); - // Cleanup partnerCode from url after storing it in localStorage to avoid re-use upon page reload - window.location.hash = window.location.hash.replace(`&partnerCode=${this.partnerCode}`, '').replace(`#partnerCode=${this.partnerCode}`, ''); - } + this.setupPartnerCode(); + this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { + this.consumePartnerCodeFragment(fragment); + }); this.acceleratorAvailable = this.stateService.env.OFFICIAL_MEMPOOL_SPACE && this.stateService.env.ACCELERATOR && this.stateService.network === ''; @@ -869,6 +866,36 @@ export class TrackerComponent implements OnInit, OnDestroy { this.miningSubscription?.unsubscribe(); this.currencyChangeSubscription?.unsubscribe(); this.enterpriseInfo$?.unsubscribe(); + this.urlFragmentSubscription?.unsubscribe(); this.leaveTransaction(); + this.partnerCodeSubscription?.unsubscribe(); + } + + setupPartnerCode(): void { + this.partnerCodeSubscription?.unsubscribe(); + this.partnerCodeSubscription = this.partnerCodeService.partnerCode$.subscribe((partnerCode) => { + this.partnerCode = partnerCode; + this.cd.markForCheck(); + }); + } + + // extract the partnerCode from the fragment string, pass to the partnerCodeService, and then remove from the URL + private consumePartnerCodeFragment(fragment: string | null): void { + const fragmentParams = new URLSearchParams(fragment || ''); + if (!fragmentParams.has('partnerCode')) { + return; + } + + const partnerCode = fragmentParams.get('partnerCode'); + if (partnerCode) { + this.partnerCodeService.setFragmentPartnerCode(partnerCode); + } + fragmentParams.delete('partnerCode'); + this.router.navigate([], { + relativeTo: this.route, + fragment: fragmentParams.toString() || null, + queryParamsHandling: 'merge', + replaceUrl: true, + }); } } diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index b08b9729b..de4936f12 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -15,7 +15,6 @@ import { startWith, repeat, take, - debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { Transaction } from '@interfaces/electrs.interface'; @@ -37,6 +36,7 @@ import { PriceService } from '@app/services/price.service'; import { isFeatureActive } from '@app/bitcoin.utils'; import { ServicesApiServices } from '@app/services/services-api.service'; import { EnterpriseService } from '@app/services/enterprise.service'; +import { PartnerCodeService } from '@app/services/partner-code.service'; import { ZONE_SERVICE } from '@app/injection-tokens'; import { MiningService, MiningStats } from '@app/services/mining.service'; import { ETA, EtaService } from '@app/services/eta.service'; @@ -104,6 +104,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { auditSubscription: Subscription; txConfirmedSubscription: Subscription; currencyChangeSubscription: Subscription; + partnerCodeSubscription: Subscription; fragmentParams: URLSearchParams; rbfTransaction: undefined | Transaction; replaced: boolean = false; @@ -215,6 +216,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { private priceService: PriceService, private storageService: StorageService, private enterpriseService: EnterpriseService, + private partnerCodeService: PartnerCodeService, private miningService: MiningService, private etaService: EtaService, private cd: ChangeDetectorRef, @@ -222,6 +224,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { ) {} ngOnInit() { + this.setupPartnerCode(); + this.enterpriseService.page(); this.isDetailsOpen = this.route.snapshot.queryParams['showDetails'] === 'true'; this.cpfpMode = this.route.snapshot.queryParams['cpfp'] === 'true'; @@ -229,15 +233,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { const urlParams = new URLSearchParams(window.location.search); this.forceAccelerationSummary = !!urlParams.get('cash_request_id'); - // Accelerator partner code - const fragmentsParams = new URLSearchParams(window.location.hash.slice(1)); - this.partnerCode = fragmentsParams.get('partnerCode') ?? this.storageService.getValue('partnerCode') ?? undefined; - if (this.partnerCode) { - this.storageService.setValue('partnerCode', this.partnerCode); - // Cleanup partnerCode from url after storing it in localStorage to avoid re-use upon page reload - window.location.hash = window.location.hash.replace(`&partnerCode=${this.partnerCode}`, '').replace(`#partnerCode=${this.partnerCode}`, ''); - } - this.hideAccelerationSummary = this.stateService.isMempoolSpaceBuild ? this.storageService.getValue('hide-accelerator-pref') == 'true' : true; if (!this.stateService.isLiquid()) { @@ -1145,7 +1140,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { if (anchor) { params.set(anchor, ''); } - return Array.from(params.entries()).map(([key, value]) => `${key}=${value}`).join('&') || null; + return Array.from(params.entries()).map(([key, value]) => value ? `${key}=${value}` : key).join('&') || null; } toggleGraph() { @@ -1187,6 +1182,22 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { updateFragmentParams(fragment: string | null): void { this.fragmentParams = new URLSearchParams(fragment || ''); + // remove any partnerCode param from the URL and pass it to the partnerCodeService + if (this.fragmentParams.has('partnerCode')) { + const partnerCode = this.fragmentParams.get('partnerCode'); + if (partnerCode) { + this.partnerCodeService.setFragmentPartnerCode(partnerCode); + } + this.fragmentParams.delete('partnerCode'); + // update the URL with the new fragment string in a way Angular Router understands + const anchor = Array.from(this.fragmentParams.entries()).find(([, value]) => value === '')?.[0] || null; + this.router.navigate([], { + relativeTo: this.route, + fragment: this.formatFragment(this.fragmentParams, anchor), + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } const vin = parseInt(this.fragmentParams.get('vin'), 10); const vout = parseInt(this.fragmentParams.get('vout'), 10); const inputIndex = (!isNaN(vin) && vin >= 0) ? vin : null; @@ -1298,5 +1309,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.txConfirmedSubscription?.unsubscribe(); this.currencyChangeSubscription?.unsubscribe(); this.leaveTransaction(); + this.partnerCodeSubscription?.unsubscribe(); + } + + setupPartnerCode(): void { + this.partnerCodeSubscription?.unsubscribe(); + this.partnerCodeSubscription = this.partnerCodeService.partnerCode$.subscribe((partnerCode) => { + this.partnerCode = partnerCode; + }); } } diff --git a/frontend/src/app/services/partner-code.service.ts b/frontend/src/app/services/partner-code.service.ts new file mode 100644 index 000000000..da6181386 --- /dev/null +++ b/frontend/src/app/services/partner-code.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, shareReplay } from 'rxjs/operators'; +import { EnterpriseService } from '@app/services/enterprise.service'; +import { StorageService } from '@app/services/storage.service'; + +@Injectable({ + providedIn: 'root' +}) +export class PartnerCodeService { + private readonly storedPartnerCode$: BehaviorSubject; + + readonly partnerCode$: Observable; + + constructor( + private enterpriseService: EnterpriseService, + private storageService: StorageService, + ) { + // holds the most recent unused one-off code + const storedPartnerCode = this.storageService.getValue('partnerCode'); + const isEnterprise = !!this.enterpriseService.getSubdomain(); + this.storedPartnerCode$ = new BehaviorSubject(storedPartnerCode || undefined); + + const enterprisePartnerCode$ = this.enterpriseService.info$.pipe( + filter((info: object | null) => !isEnterprise || info !== null), + map((info: object | null) => (info as { name?: string } | null)?.name || undefined), + ); + + // enterprise partner code takes precedence, and remains applicable for all requests on this instance + // one-off code applies to the first successful request after being set, iff no enterprise code is present + this.partnerCode$ = combineLatest([ + enterprisePartnerCode$, + this.storedPartnerCode$, + ]).pipe( + map(([enterpriseCode, storedCode]) => enterpriseCode || storedCode), + distinctUntilChanged(), + shareReplay(1), + ); + } + + // set a one-off partner code from a URL fragment + setFragmentPartnerCode(partnerCode: string): void { + this.storageService.setValue('partnerCode', partnerCode); + this.storedPartnerCode$.next(partnerCode); + } + + // clear any one-off code after successful use + clearStoredPartnerCode(): void { + this.storageService.removeItem('partnerCode'); + this.storedPartnerCode$.next(undefined); + } +}