diff --git a/frontend/src/app/components/payment/payment.component.html b/frontend/src/app/components/payment/payment.component.html
new file mode 100644
index 000000000..afd592856
--- /dev/null
+++ b/frontend/src/app/components/payment/payment.component.html
@@ -0,0 +1,193 @@
+
+
+ @if (!error || loadingCachedTx) {
+
+
+
Bitcoin payment
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (isMobile) {
+
+ } @else {
+
+
+
+
+ }
+
+
+
+
+
+
+
+ }
+
+ @if (error && !loadingCachedTx) {
+
+ Error loading transaction data.
+
+ }
+
+
+
+
+
+ @if (viewAmountMode$ | async; as mode) {
+
+
+ }
+
+
+
+
+
+
+
+
+
+ @if (isValidView) {
+
+ | Address |
+
+
+ |
+
+ } @else {
+
+ }
+
+
+
+ @if (tx) {
+
+ | Amount |
+
+
+
+
+
+ |
+
+ } @else {
+
+ }
+
+
+
+ @if (tx && !replaced) {
+
+ | Status |
+
+ @if (!tx.status?.confirmed) {
+
+ @if (eta.blocks >= 7) {
+ Not any time soon
+ } @else {
+
+ }
+
+
+
+
+ } @else {
+
+ }
+ |
+
+ } @else if (replaced) {
+ } @else {
+
+ }
+
+
+
+ @if (settled) {
+ Settled
+ } @else if (isValidView && confsRequired > 1) {
+
+
+ {{ confirmations }} of {{ confsRequired }} Confirmations
+
+
+
+ } @else {
+
+ }
+
+
+
+
+ @for (conf of confsRequiredArr; track $index) {
+
+
+
+ }
+
+
+
+
+ @if (replaced) {
+
+ } @else if (tx && !isCached) {
+
+ } @else {
+
+ }
+
+
+
+
+ |
+
+
\ No newline at end of file
diff --git a/frontend/src/app/components/payment/payment.component.scss b/frontend/src/app/components/payment/payment.component.scss
new file mode 100644
index 000000000..61289bd1f
--- /dev/null
+++ b/frontend/src/app/components/payment/payment.component.scss
@@ -0,0 +1,242 @@
+
+.title-block {
+ flex-wrap: wrap;
+ align-items: baseline;
+
+ @media (min-width: 650px) {
+ flex-direction: row;
+ }
+
+ h1 {
+ margin: 0;
+ margin-right: 15px;
+ line-height: 1;
+ }
+}
+
+.tx-link {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: baseline;
+ width: 0;
+ max-width: 100%;
+ margin-top: 8px;
+ @media (min-width: 651px) {
+ flex-grow: 1;
+ margin-right: 1em;
+ top: 1px;
+ position: relative;
+ }
+ @media (max-width: 650px) {
+ width: 100%;
+ order: 3;
+ }
+
+ .txid {
+ width: 200px;
+ min-width: 200px;
+ flex-grow: 1;
+ }
+}
+
+.container-buttons {
+ align-self: center;
+}
+
+.row {
+ flex-direction: column;
+ @media (min-width: 850px) {
+ flex-direction: row;
+ }
+}
+
+.td-width {
+ width: 150px;
+ @media (max-width: 768px) {
+ width: 100px;
+ }
+}
+
+.table {
+ width: 100%;
+ table-layout: fixed;
+ tr td {
+ padding: 0.75rem 0.5rem;
+ @media (min-width: 576px) {
+ padding: 0.75rem 0.75rem;
+ }
+ &:last-child {
+ text-align: right;
+ @media (min-width: 850px) {
+ text-align: left;
+ }
+ }
+
+ &.wrap-cell {
+ white-space: normal;
+ }
+ }
+}
+
+.address ::ng-deep .truncate {
+ justify-content: end !important;
+ @media (min-width: 850px) {
+ justify-content: start !important;
+ }
+}
+
+.fiat {
+ display: block;
+ @media (min-width: 768px) {
+ display: inline-block;
+ margin-left: 15px;
+ text-align: left;
+ }
+}
+
+.badge {
+ position: relative;
+ top: -1px;
+}
+
+.details-link {
+ margin-top: 20px;
+ text-align: center;
+}
+
+.tracker-bar {
+ padding: 1em 0.5em 0;
+ margin-bottom: 1em;
+ width: 100%;
+}
+
+.settled {
+ color: var(--green);
+ opacity: 0.8;
+}
+
+.confs-cell {
+ display: flex;
+ gap: 1em;
+ flex-direction: row;
+ align-items: center;
+ justify-content: end;
+ flex-wrap: wrap;
+
+ @media (min-width: 850px) {
+ justify-content: start;
+ }
+}
+
+.confs {
+ display: flex;
+ gap: 2px;
+}
+
+.confirmation {
+ fill: var(--green);
+ fill-opacity: 0.8;
+
+ &.pending {
+ fill-opacity: 0.25;
+ &.animate {
+ animation: pulse 2s ease-in-out infinite;
+ }
+ }
+}
+
+.alert-replaced {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: var(--red);
+ margin-bottom: 1em;
+ padding: 0.5em;
+ border-radius: 0.5em;
+
+ @media (max-width: 850px) {
+ margin-top: 1em;
+ }
+
+ a {
+ overflow-wrap: anywhere;
+ }
+}
+
+.amount-side {
+ @media (min-width: 850px) {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+}
+
+.amount-col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ padding: 1.5rem 1rem;
+ background-color: var(--stat-box-bg);
+
+ .text {
+ font-size: 0.8125rem;
+ letter-spacing: 0.05em;
+ color: var(--transparent-fg);
+ }
+
+ app-amount {
+ font-size: 2rem;
+ font-weight: 600;
+ line-height: 1.1;
+ }
+
+ .fiat {
+ display: block;
+ margin-left: 0;
+ font-size: 1rem;
+ text-align: center;
+ color: var(--transparent-fg);
+ }
+}
+
+.toggler {
+ font-size: 12px;
+ position: absolute;
+ top: -20px;
+ right: 3px;
+ text-align: right;
+
+ @media (max-width: 850px) {
+ top: 2px;
+ }
+}
+
+.toggler-option {
+ text-decoration: none;
+}
+
+.inactive {
+ color: var(--transparent-fg);
+}
+
+.amount {
+ text-decoration: none;
+ color: white;
+ width: 100%;
+ text-align: center;
+}
+
+@keyframes pulse {
+ 0% {
+ fill-opacity: 0.30;
+ }
+ 50% {
+ fill-opacity: 0.6;
+ }
+ 100% {
+ fill-opacity: 0.30;
+ }
+}
diff --git a/frontend/src/app/components/payment/payment.component.ts b/frontend/src/app/components/payment/payment.component.ts
new file mode 100644
index 000000000..d41f9da46
--- /dev/null
+++ b/frontend/src/app/components/payment/payment.component.ts
@@ -0,0 +1,482 @@
+import { Component, OnInit, OnDestroy, HostListener, ChangeDetectorRef, Inject } from '@angular/core';
+import { ElectrsApiService } from '@app/services/electrs-api.service';
+import { ActivatedRoute, ParamMap, Router } from '@angular/router';
+import { switchMap, filter, catchError, map, startWith, distinctUntilChanged, tap } from 'rxjs/operators';
+import { Transaction } from '@interfaces/electrs.interface';
+import { of, merge, Subscription, Observable, combineLatest, BehaviorSubject, Subject } from 'rxjs';
+import { StateService } from '@app/services/state.service';
+import { AudioService } from '@app/services/audio.service';
+import { CacheService } from '@app/services/cache.service';
+import { WebsocketService } from '@app/services/websocket.service';
+import { SeoService } from '@app/services/seo.service';
+import { seoDescriptionNetwork } from '@app/shared/common.utils';
+import { getUnacceleratedFeeRate } from '@app/shared/transaction.utils';
+import { BlockExtended, MempoolPosition, AccelerationPosition, RbfTree } from '@interfaces/node-api.interface';
+import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
+import { ZONE_SERVICE } from '@app/injection-tokens';
+import { MiningService, MiningStats } from '@app/services/mining.service';
+import { ETA, EtaService } from '@app/services/eta.service';
+import { getRegex } from '@app/shared/regex.utils';
+import { TrackerStage } from '@components/tracker/tracker-bar.component';
+import { ApiService } from '@app/services/api.service';
+import { StorageService } from '@app/services/storage.service';
+
+@Component({
+ selector: 'app-payment',
+ templateUrl: './payment.component.html',
+ styleUrls: ['./payment.component.scss'],
+ standalone: false,
+})
+export class PaymentComponent implements OnInit, OnDestroy {
+ network = '';
+ tx: Transaction;
+ txId: string;
+ mempoolPosition: MempoolPosition;
+ accelerationPositions: AccelerationPosition[];
+ latestBlock: BlockExtended;
+ isLoadingTx = true;
+ error: any = undefined;
+ waitingForTransaction = false;
+ isMobile: boolean;
+ isValidView = false;
+ rbfTransaction: Transaction | null;
+
+ destination = '';
+ confsRequired = 1;
+ amount = 0;
+ confirmations = 0;
+ settled = false;
+ replaced = false;
+ trackerStage: TrackerStage = 'waiting';
+
+ miningStats: MiningStats;
+ ETA$: Observable;
+ txChanged$ = new BehaviorSubject(false);
+ isAccelerated$ = new BehaviorSubject(false);
+
+ isCached: boolean;
+ rbfInfo: RbfTree;
+ fetchRbfHistory$ = new Subject();
+ fetchCachedTx$ = new Subject();
+ loadingCachedTx: boolean;
+
+ subscription: Subscription;
+ networkChangedSubscription: Subscription;
+ blocksSubscription: Subscription;
+ mempoolPositionSubscription: Subscription;
+ txConfirmedSubscription: Subscription;
+ txReplacedSubscription: Subscription;
+ fetchRbfSubscription: Subscription;
+ fetchCachedTxSubscription: Subscription;
+ txRbfInfoSubscription: Subscription;
+ latestReplacement: string;
+ amountMode: string;
+ viewAmountMode$: Observable<'btc' | 'sats' | 'fiat'>;
+
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private relativeUrlPipe: RelativeUrlPipe,
+ private apiService: ApiService,
+ private electrsApiService: ElectrsApiService,
+ public stateService: StateService,
+ private audioService: AudioService,
+ private cacheService: CacheService,
+ private websocketService: WebsocketService,
+ private seoService: SeoService,
+ private miningService: MiningService,
+ private etaService: EtaService,
+ private storageService: StorageService,
+ private cd: ChangeDetectorRef,
+ @Inject(ZONE_SERVICE) private zoneService: any,
+ ) {}
+
+ ngOnInit(): void {
+ this.onResize();
+ this.viewAmountMode$ = this.stateService.viewAmountMode$.asObservable();
+
+ if (!this.stateService.isLiquid()) {
+ this.miningService.getMiningStats('1m').subscribe(stats => {
+ this.miningStats = stats;
+ });
+ }
+
+ this.websocketService.want(['blocks', 'mempool-blocks']);
+
+ this.networkChangedSubscription = this.stateService.networkChanged$.subscribe((network) => {
+ this.network = network;
+ });
+
+ this.blocksSubscription = this.stateService.blocks$.subscribe((blocks) => {
+ this.latestBlock = blocks[0];
+ this.updateConfirmations();
+ this.cd.markForCheck();
+ });
+
+ this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => {
+ if (txPosition && txPosition.txid === this.txId && txPosition.position) {
+ this.mempoolPosition = txPosition.position;
+ this.accelerationPositions = txPosition.accelerationPositions;
+ this.isAccelerated$.next(!!(this.tx?.acceleration || txPosition.position?.accelerated));
+ if (this.tx && !this.tx.status.confirmed) {
+ this.markBlock();
+ }
+ } else {
+ this.mempoolPosition = null;
+ this.accelerationPositions = null;
+ this.isAccelerated$.next(false);
+ }
+ });
+
+ this.fetchRbfSubscription = this.fetchRbfHistory$
+ .pipe(
+ switchMap((txId) =>
+ this.apiService
+ .getRbfHistory$(txId)
+ ),
+ catchError(() => {
+ return of(null);
+ })
+ ).subscribe((rbfResponse) => {
+ this.rbfInfo = rbfResponse?.replacements;
+ if (this.rbfInfo) {
+ // link to the latest pending version
+ this.latestReplacement = this.rbfInfo.tx.txid;
+ // or traverse the rbf tree to find a confirmed version
+ if (this.rbfInfo.mined) {
+ const stack = [this.rbfInfo];
+ let found = false;
+ while (stack.length && !found) {
+ const top = stack.pop();
+ if (top?.tx.mined) {
+ found = true;
+ this.latestReplacement = top.tx.txid;
+ break;
+ } else {
+ stack.push(...top.replaces);
+ }
+ }
+ }
+ }
+ });
+
+ this.fetchCachedTxSubscription = this.fetchCachedTx$
+ .pipe(
+ tap(() => {
+ this.loadingCachedTx = true;
+ }),
+ switchMap((txId) =>
+ this.apiService
+ .getRbfCachedTx$(txId)
+ ),
+ catchError(() => {
+ return of(null);
+ })
+ ).subscribe((tx) => {
+ this.loadingCachedTx = false;
+ if (!tx) {
+ this.seoService.logSoft404();
+ return;
+ }
+ this.seoService.clearSoft404();
+
+ if (!this.tx) {
+ this.tx = tx;
+ this.isCached = true;
+ if (tx.fee === undefined) {
+ this.tx.fee = 0;
+ }
+ this.tx.feePerVsize = tx.fee / (tx.weight / 4);
+ this.isLoadingTx = false;
+ this.error = undefined;
+ this.waitingForTransaction = false;
+
+ if (!this.isValidDestination(tx)) {
+ this.viewFullDetails();
+ return;
+ }
+
+ this.fetchRbfHistory$.next(this.tx.txid);
+ this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => {
+ if (this.tx) {
+ this.rbfInfo = rbfInfo;
+ }
+ });
+ this.setAmount();
+ this.txChanged$.next(true);
+ }
+ });
+
+ this.subscription = this.zoneService.wrapObservable(this.route.paramMap
+ .pipe(
+ switchMap((params: ParamMap) => {
+ this.txId = params.get('id');
+ this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`);
+ const network = this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ? 'Liquid' : 'Bitcoin';
+ const seoDescription = seoDescriptionNetwork(this.stateService.network);
+ this.seoService.setDescription($localize`:@@meta.description.bitcoin.transaction:Get real-time status, addresses, fees, script info, and more for ${network}${seoDescription} transaction with txid ${this.txId}.`);
+ this.resetTransaction();
+
+ return merge(
+ of(true),
+ this.stateService.connectionState$.pipe(
+ filter((state) => state === 2 && this.tx && !this.tx.status?.confirmed)
+ )
+ );
+ }),
+ switchMap(() => {
+ let transactionObservable$: Observable;
+ const cached = this.cacheService.getTxFromCache(this.txId);
+ if (cached && cached.fee !== -1) {
+ transactionObservable$ = of(cached);
+ } else {
+ transactionObservable$ = this.electrsApiService
+ .getTransaction$(this.txId)
+ .pipe(catchError(this.handleLoadElectrsTransactionError.bind(this)));
+ }
+ return merge(transactionObservable$, this.stateService.mempoolTransactions$);
+ }),
+ ))
+ .subscribe((tx: Transaction) => {
+ if (!tx) {
+ this.fetchCachedTx$.next(this.txId);
+ this.seoService.logSoft404();
+ return;
+ }
+ this.seoService.clearSoft404();
+
+ this.tx = tx;
+ this.isCached = false;
+ this.isAccelerated$.next(!!(tx.acceleration || this.mempoolPosition?.accelerated));
+ if (tx.fee === undefined) {
+ this.tx.fee = 0;
+ }
+ this.tx.feePerVsize = tx.fee / (tx.weight / 4);
+ this.isLoadingTx = false;
+ this.error = undefined;
+ this.waitingForTransaction = false;
+
+ // The payment view only makes sense for a real output of this transaction.
+ // Otherwise fall back to the full transaction page.
+ if (!this.isValidDestination(tx)) {
+ this.viewFullDetails();
+ return;
+ }
+
+ if (!tx.status.confirmed) {
+ this.trackerStage = 'pending';
+ } else {
+ this.trackerStage = 'confirmed';
+ }
+
+ this.websocketService.startTrackTransaction(tx.txid);
+ this.setAmount();
+ this.updateConfirmations();
+ this.txChanged$.next(true);
+ this.markBlock();
+
+ this.cd.detectChanges();
+ },
+ (error) => {
+ this.error = error;
+ this.seoService.logSoft404();
+ this.isLoadingTx = false;
+ });
+
+ this.txConfirmedSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => {
+ if (txConfirmed && this.tx && !this.tx.status.confirmed && txConfirmed === this.tx.txid) {
+ this.tx.status = {
+ confirmed: true,
+ block_height: block.height,
+ block_hash: block.id,
+ block_time: block.timestamp,
+ };
+ this.txChanged$.next(true);
+ this.updateConfirmations();
+ this.stateService.markBlock$.next({ blockHeight: block.height });
+ this.trackerStage = 'confirmed';
+ this.audioService.playSound('magic');
+ this.cd.markForCheck();
+ }
+ });
+
+ this.txReplacedSubscription = this.stateService.txReplaced$.subscribe((rbfTx) => {
+ if (!this.tx) {
+ this.error = new Error();
+ this.loadingCachedTx = false;
+ this.waitingForTransaction = false;
+ }
+
+ this.rbfTransaction = rbfTx;
+ this.replaced = true;
+ this.trackerStage = 'replaced';
+ if (!this.rbfInfo && rbfTx) {
+ this.latestReplacement = rbfTx.txid;
+ }
+ this.stateService.markBlock$.next({});
+
+ if (rbfTx && !this.tx) {
+ this.fetchCachedTx$.next(this.txId);
+ }
+ this.cd.markForCheck();
+ });
+
+ this.ETA$ = combineLatest([
+ this.stateService.mempoolTxPosition$.pipe(startWith(null)),
+ this.stateService.mempoolBlocks$.pipe(startWith(null)),
+ this.stateService.difficultyAdjustment$.pipe(startWith(null)),
+ this.isAccelerated$,
+ this.txChanged$,
+ ]).pipe(
+ map(([position, mempoolBlocks, da, isAccelerated]) => {
+ if (!this.tx || this.tx.status?.confirmed || !position || position.txid !== this.tx.txid) {
+ return null;
+ }
+ return this.etaService.calculateETA(
+ this.network,
+ this.tx,
+ mempoolBlocks,
+ position,
+ da,
+ this.miningStats,
+ isAccelerated,
+ this.accelerationPositions,
+ );
+ }),
+ distinctUntilChanged((prev: ETA | null, curr: ETA | null) => {
+ return prev === curr || (prev && curr && prev.time === curr.time && prev.blocks === curr.blocks);
+ }),
+ tap((eta) => {
+ if (this.replaced) {
+ this.trackerStage = 'replaced';
+ } else if (eta?.blocks === 0) {
+ this.trackerStage = 'next';
+ } else if (eta?.blocks < 3){
+ this.trackerStage = 'soon';
+ } else {
+ this.trackerStage = 'pending';
+ }
+ })
+ );
+ }
+
+ isValidDestination(tx: Transaction): boolean {
+ const network = (this.network || 'mainnet') as any;
+ if (!this.destination || !getRegex('address', network).test(this.destination)) {
+ return false;
+ }
+
+ this.isValidView = tx?.vout?.some(vout => vout.scriptpubkey_address === this.destination);
+ return this.isValidView;
+ }
+
+ setAmount(): void {
+ this.amount = (this.tx?.vout || []).reduce((total, vout) => {
+ return vout.scriptpubkey_address === this.destination ? total + vout.value : total;
+ }, 0);
+ }
+
+ updateConfirmations(): void {
+ if (this.tx?.status?.confirmed && this.latestBlock?.height != null) {
+ this.confirmations = Math.max(1, this.latestBlock.height - this.tx.status.block_height + 1);
+ } else {
+ this.confirmations = 0;
+ }
+ this.settled = this.confirmations >= this.confsRequired;
+ }
+
+ markBlock(): void {
+ if (this.tx?.status?.confirmed) {
+ this.stateService.markBlock$.next({ blockHeight: this.tx.status.block_height });
+ } else if (this.tx) {
+ const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated);
+ this.stateService.markBlock$.next({
+ txid: this.tx.txid,
+ txFeePerVSize,
+ mempoolPosition: this.mempoolPosition,
+ accelerationPositions: this.accelerationPositions,
+ });
+ }
+ }
+
+ viewFullDetails(): void {
+ this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId]);
+ }
+
+ get confsRequiredArr(): number[] {
+ return Array(this.confsRequired);
+ }
+
+ handleLoadElectrsTransactionError(error: any): Observable {
+ if (error.status === 404 && /^[a-fA-F0-9]{64}$/.test(this.txId)) {
+ this.websocketService.startMultiTrackTransaction(this.txId);
+ this.waitingForTransaction = true;
+ }
+ this.error = error;
+ this.seoService.logSoft404();
+ this.isLoadingTx = false;
+ return of(false);
+ }
+
+ getFragment(destination: string, confs: number = 1): string {
+ return `destination=${destination}&confs=${confs}`;
+ }
+
+ resetTransaction(): void {
+ const fragmentParams = new URLSearchParams(this.route.snapshot.fragment || '');
+ this.destination = fragmentParams.get('destination') || '';
+ const parsedConfs = Math.min(Math.ceil(parseInt((fragmentParams.get('confs') || '1'), 10)), 6); // Capped to 6 confs
+ this.confsRequired = (!isNaN(parsedConfs) && parsedConfs >= 1) ? parsedConfs : 1;
+
+ this.error = undefined;
+ this.tx = null;
+ this.txChanged$.next(true);
+ this.waitingForTransaction = false;
+ this.isLoadingTx = true;
+ this.isValidView = false;
+ this.replaced = false;
+ this.rbfTransaction = null;
+ this.rbfInfo = null;
+ this.latestReplacement = '';
+ this.isCached = false;
+ this.loadingCachedTx = false;
+ this.trackerStage = 'waiting';
+ this.amount = 0;
+ this.confirmations = 0;
+ this.settled = false;
+ this.mempoolPosition = null;
+ this.accelerationPositions = null;
+ this.isAccelerated$.next(false);
+ this.txRbfInfoSubscription?.unsubscribe();
+ this.leaveTransaction();
+ }
+
+ leaveTransaction(): void {
+ this.websocketService.stopTrackingTransaction();
+ this.stateService.markBlock$.next({});
+ }
+
+ @HostListener('window:resize', ['$event'])
+ onResize(): void {
+ this.isMobile = window.innerWidth < 850;
+ }
+
+ ngOnDestroy(): void {
+ this.fetchRbfSubscription?.unsubscribe();
+ this.fetchCachedTxSubscription?.unsubscribe();
+ this.subscription?.unsubscribe();
+ this.networkChangedSubscription?.unsubscribe();
+ this.blocksSubscription?.unsubscribe();
+ this.mempoolPositionSubscription?.unsubscribe();
+ this.txConfirmedSubscription?.unsubscribe();
+ this.txReplacedSubscription?.unsubscribe();
+ this.leaveTransaction();
+ }
+
+ changeMode(mode: 'btc' | 'sats'): boolean {
+ this.storageService.setValue('view-amount-mode', mode);
+ this.stateService.viewAmountMode$.next(mode);
+ this.amountMode = mode;
+ return false;
+ }
+}
diff --git a/frontend/src/app/components/payment/payment.module.ts b/frontend/src/app/components/payment/payment.module.ts
new file mode 100644
index 000000000..0840f3271
--- /dev/null
+++ b/frontend/src/app/components/payment/payment.module.ts
@@ -0,0 +1,50 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Routes, RouterModule } from '@angular/router';
+import { SharedModule } from '@app/shared/shared.module';
+import { PaymentComponent } from '@components/payment/payment.component';
+import { TrackerModule } from '@components/tracker/tracker.module';
+
+const routes: Routes = [
+ {
+ path: '',
+ redirectTo: '/',
+ pathMatch: 'full',
+ },
+ {
+ path: ':id',
+ component: PaymentComponent,
+ data: {
+ ogImage: true
+ }
+ }
+];
+
+@NgModule({
+ imports: [
+ RouterModule.forChild(routes)
+ ],
+ exports: [
+ RouterModule
+ ]
+})
+export class PaymentRoutingModule { }
+
+@NgModule({
+ imports: [
+ CommonModule,
+ PaymentRoutingModule,
+ SharedModule,
+ TrackerModule
+ ],
+ declarations: [
+ PaymentComponent
+ ]
+})
+export class PaymentModule { }
+
+
+
+
+
+
diff --git a/frontend/src/app/components/tracker/tracker.module.ts b/frontend/src/app/components/tracker/tracker.module.ts
index b41a68bc5..b1c8b8ba6 100644
--- a/frontend/src/app/components/tracker/tracker.module.ts
+++ b/frontend/src/app/components/tracker/tracker.module.ts
@@ -40,6 +40,9 @@ export class TrackerRoutingModule { }
declarations: [
TrackerComponent,
TrackerBarComponent,
+ ],
+ exports: [
+ TrackerBarComponent
]
})
export class TrackerModule { }
diff --git a/frontend/src/app/master-page.module.ts b/frontend/src/app/master-page.module.ts
index 78c46916a..f4cdee05d 100644
--- a/frontend/src/app/master-page.module.ts
+++ b/frontend/src/app/master-page.module.ts
@@ -17,6 +17,7 @@ import { ServerStatusComponent } from '@components/server-health/server-status.c
import { FaucetComponent } from '@components/faucet/faucet.component';
import { SimpleProofWidgetComponent } from '@components/simpleproof-widget/simpleproof-widget.component';
import { SimpleProofCuboWidgetComponent } from '@components/simpleproof-widget/simpleproof-cubo-widget.component';
+import { PaymentGuard } from '@app/route-guards';
const browserWindow = window || {};
// @ts-ignore
@@ -93,6 +94,15 @@ const routes: Routes = [
{
path: 'tx',
component: StartComponent,
+ canMatch: [PaymentGuard],
+ runGuardsAndResolvers: 'always',
+ data: { preload: false, networkSpecific: true },
+ loadChildren: () => import('@components/payment/payment.module').then(m => m.PaymentModule),
+ },
+ {
+ path: 'tx',
+ component: StartComponent,
+ runGuardsAndResolvers: 'always',
data: { preload: true, networkSpecific: true },
loadChildren: () => import('@components/transaction/transaction.module').then(m => m.TransactionModule),
},
diff --git a/frontend/src/app/route-guards.ts b/frontend/src/app/route-guards.ts
index 81cbf03ae..3864ea200 100644
--- a/frontend/src/app/route-guards.ts
+++ b/frontend/src/app/route-guards.ts
@@ -14,10 +14,19 @@ class GuardService {
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments;
- return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path));
+ return (!this.paymentGuard() && (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path)));
+ }
+
+ paymentGuard(route?: Route, segments?: UrlSegment[]): boolean {
+ const fragmentParams = new URLSearchParams(this.router.getCurrentNavigation()?.extractedUrl.fragment || '');
+ return (fragmentParams.has('destination'));
}
}
export const TrackerGuard: CanMatchFn = (route: Route, segments: UrlSegment[]): boolean => {
return inject(GuardService).trackerGuard(route, segments);
};
+
+export const PaymentGuard: CanMatchFn = (route: Route, segments: UrlSegment[]): boolean => {
+ return inject(GuardService).paymentGuard(route, segments);
+};