demo integration of matomo replacement

This commit is contained in:
Mononaut 2026-02-26 10:11:14 +00:00 committed by mononaut
parent 8a9904dbe6
commit da8981018d
No known key found for this signature in database
GPG key ID: BFD16BE592A9CD8D
8 changed files with 332 additions and 1 deletions

View file

@ -29,6 +29,7 @@ import { ShortenStringPipe } from '@app/shared/pipes/shorten-string-pipe/shorten
import { CapAddressPipe } from '@app/shared/pipes/cap-address-pipe/cap-address-pipe';
import { AppPreloadingStrategy } from '@app/app.preloading-strategy';
import { ServicesApiServices } from '@app/services/services-api.service';
import { AnalyticsService } from '@app/services/analytics.service';
import { DatePipe } from '@angular/common';
const providers = [
@ -53,6 +54,7 @@ const providers = [
AppPreloadingStrategy,
ServicesApiServices,
PreloadService,
AnalyticsService,
{ provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true },
{ provide: ZONE_SERVICE, useClass: ZoneService },
];

View file

@ -632,7 +632,7 @@
<ng-template #accelerateButton>
<div class="position-relative">
<button type="button" class="mt-1 btn btn-purple rounded-pill align-self-center d-flex flex-row justify-content-center align-items-center" [class.disabled]="!canPay || quoteError || cantPayReason || calculating || (!advancedEnabled && selectedOption !== 'accel')" style="width: 200px" (click)="moveToStep('checkout')">
<button type="button" class="mt-1 btn btn-purple rounded-pill align-self-center d-flex flex-row justify-content-center align-items-center" [class.disabled]="!canPay || quoteError || cantPayReason || calculating || (!advancedEnabled && selectedOption !== 'accel')" style="width: 200px" (click)="onAccelerateClicked()">
<img src="/resources/mempool-accelerator-sparkles-light.svg" height="20" class="me-2" style="margin-left: -10px">
<span i18n="transaction.accelerate|Accelerate button label">Accelerate</span>
</button>

View file

@ -14,6 +14,9 @@ import { PartnerCodeService } from '@app/services/partner-code.service';
import { ApiService } from '@app/services/api.service';
import { isDevMode } from '@angular/core';
import { ThemeService } from '../../services/theme.service';
import { StorageService } from '@app/services/storage.service';
import { AnalyticsService } from '@app/services/analytics.service';
import { Recommendedfees } from '@interfaces/websocket.interface';
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay' | 'cardOnFile';
@ -105,6 +108,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
accelerationSubscription: Subscription;
difficultySubscription: Subscription;
estimateSubscription: Subscription;
recommendedFeesSubscription: Subscription;
estimate: AccelerationEstimate;
estimate$: ReplaySubject<AccelerationEstimate | null> = new ReplaySubject(1);
maxBidBoost: number; // sats
@ -119,6 +123,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
userBid = 0;
selectFeeRateIndex = 1;
maxRateOptions: RateOption[] = [];
recommendedFees: Recommendedfees | null = null;
// square
loadingCashapp = false;
@ -150,6 +155,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
private enterpriseService: EnterpriseService,
private partnerCodeService: PartnerCodeService,
private themeService: ThemeService,
private analyticsService: AnalyticsService,
private storageService: StorageService,
) {
this.isProdDomain = this.stateService.isProdDomain;
@ -198,6 +205,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.loadedTheme = state.theme;
this.cd.markForCheck();
});
this.recommendedFeesSubscription = this.stateService.recommendedFees$.subscribe(
(recommendedFees) => {
this.recommendedFees = recommendedFees;
}
);
}
ngOnDestroy(): void {
@ -210,6 +222,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
if (this.themeStateSubscription) {
this.themeStateSubscription.unsubscribe();
}
if (this.recommendedFeesSubscription) {
this.recommendedFeesSubscription.unsubscribe();
}
this.destroy$.next();
this.destroy$.complete();
}
@ -220,6 +235,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
}
onAccelerateClicked(): void {
this.analyticsService.action('/tx/:txid/checkout/' + (this.step === 'quote' ? 'quote' : 'summary'), 'accelerate_clicked', this.anonymizedTxInfo());
this.moveToStep('checkout');
}
moveToStep(step: CheckoutStep, force: boolean = false): void {
if (this.isCheckoutLocked > 0 && !force || this.step === 'success') {
return;
@ -249,14 +269,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.moveToStep('bitcoin');
}
} else if (this._step === 'cashapp') {
this.analyticsService.action('/tx/:txid/checkout/checkout', 'cashapp_clicked', this.anonymizedTxInfo());
this.loadingCashapp = true;
this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'applepay' && this.applePayEnabled) {
this.analyticsService.action('/tx/:txid/checkout/checkout', 'applypay_clicked', this.anonymizedTxInfo());
this.loadingApplePay = true;
this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'googlepay' && this.googlePayEnabled) {
this.analyticsService.action('/tx/:txid/checkout/checkout', 'googlepay_clicked', this.anonymizedTxInfo());
this.loadingGooglePay = true;
this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
@ -267,12 +290,15 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} else if (this._step === 'bitcoin') {
this.scrollToElementWithTimeout('confirm-title', 'nearest', 100);
} else if (this._step === 'paid') {
this.analyticsService.action('/tx/:txid/checkout', 'acceleration_paid', this.anonymizedTxInfo());
this.timePaid = Date.now();
this.timeoutTimer = setTimeout(() => {
if (this.step === 'paid') {
this.accelerateError = 'internal_server_error';
}
}, 120000);
} else if (this._step === 'quote') {
this.analyticsService.action('/tx/:txid/checkout/summary', 'details_clicked', this.anonymizedTxInfo());
}
this.hasDetails.emit(this._step === 'quote');
}
@ -283,6 +309,16 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerateError = '';
}
anonymizedTxInfo(): Record<string, unknown> {
return {
vsize: this.estimate.txSummary.effectiveVsize,
fee: this.estimate.txSummary.effectiveFee,
userBid: this.userBid,
recommended: this.recommendedFees?.fastestFee,
cost: this.cost,
};
}
/**
* Scroll to element id with or without setTimeout
*/

View file

@ -6,6 +6,7 @@ import { OpenGraphService } from '@app/services/opengraph.service';
import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap';
import { ThemeService } from '@app/services/theme.service';
import { SeoService } from '@app/services/seo.service';
import { AnalyticsService } from '@app/services/analytics.service';
@Component({
selector: 'app-root',
@ -21,6 +22,7 @@ export class AppComponent implements OnInit {
private openGraphService: OpenGraphService,
private seoService: SeoService,
private themeService: ThemeService,
private analyticsService: AnalyticsService,
private location: Location,
private viewportScroller: ViewportScroller,
tooltipConfig: NgbTooltipConfig,

View file

@ -33,6 +33,8 @@ 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';
import { AnalyticsService } from '@app/services/analytics.service';
interface Pool {
@ -147,6 +149,7 @@ export class TrackerComponent implements OnInit, OnDestroy {
private priceService: PriceService,
private enterpriseService: EnterpriseService,
private partnerCodeService: PartnerCodeService,
private analyticsService: AnalyticsService,
private miningService: MiningService,
private router: Router,
private cd: ChangeDetectorRef,
@ -501,6 +504,7 @@ export class TrackerComponent implements OnInit, OnDestroy {
this.websocketService.startTrackTransaction(tx.txid);
if (!tx.status?.confirmed) {
this.analyticsService.action('/tx/:txid/tracker', 'unconfirmed-tx');
this.trackerStage = 'pending';
if (tx.firstSeen) {
this.transactionTime = tx.firstSeen;

View file

@ -40,6 +40,7 @@ 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';
import { AnalyticsService } from '@app/services/analytics.service';
export interface Pool {
id: number;
@ -219,6 +220,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
private storageService: StorageService,
private enterpriseService: EnterpriseService,
private partnerCodeService: PartnerCodeService,
private analyticsService: AnalyticsService,
private miningService: MiningService,
private etaService: EtaService,
private cd: ChangeDetectorRef,
@ -704,6 +706,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.setupGraph();
if (!tx.status?.confirmed) {
this.analyticsService.action('/tx/:txid', 'unconfirmed-tx');
if (tx.firstSeen) {
this.transactionTime = tx.firstSeen;
} else {

View file

@ -0,0 +1,283 @@
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { isPlatformBrowser } from '@angular/common';
import { filter } from 'rxjs/operators';
import { StateService } from '@app/services/state.service';
import { LanguageService } from '@app/services/language.service';
const SESSION_KEY = 'mlytx';
const SESSION_TIMEOUT_MS = 6 * 60 * 60 * 1000;
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const FLUSH_IDLE_MS = 5_000;
const FLUSH_MAX_MS = 30_000;
interface SessionEntry {
id: string;
ts: number;
st: number;
}
@Injectable({
providedIn: 'root'
})
export class AnalyticsService {
private baseUrl: string;
private sessionId: string;
private startTime: number;
private locale: string | undefined;
private buffer: Record<string, unknown>[] = [];
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private maxTimer: ReturnType<typeof setTimeout> | null = null;
public enabled = false;
constructor(
@Inject(PLATFORM_ID) platformId: any,
private router: Router,
private http: HttpClient,
private stateService: StateService,
private languageService: LanguageService,
) {
if (!isPlatformBrowser(platformId) || !this.stateService.env.ANALYTICS_URL || !this.trackingAllowed() || this.doNotTrack()) {
this.enabled = false;
return;
}
this.enabled = true;
this.baseUrl = this.stateService.env.ANALYTICS_URL;
const lang = this.languageService.getLanguage();
this.locale = lang && lang !== 'en' ? lang : undefined;
if (!this.restoreSession()) {
this.startNewSession();
}
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.unload();
}
});
window.addEventListener('beforeunload', () => {
this.unload();
});
window.addEventListener('pagehide', () => {
this.unload();
});
this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
).subscribe(() => {
const { path, network } = this.getRouteTemplate();
this.view(path, network);
});
}
view(path: string, network?: string): void {
if (!this.baseUrl || !this.enabled) {
return;
}
const event: Record<string, unknown> = { t: 'v', p: path };
if (network) {
event.n = network;
}
if (this.locale) {
event.l = this.locale;
}
this.pushEvent(event);
}
action(path: string, id: string, params?: Record<string, unknown>, network?: string): void {
if (!this.baseUrl || !this.enabled) {
return;
}
const event: Record<string, unknown> = { t: 'a', p: path, id };
if (network) {
event.n = network;
}
if (this.locale) {
event.l = this.locale;
}
if (params && Object.keys(params).length) {
event.params = params;
}
this.pushEvent(event);
}
unload(): void {
if (!this.baseUrl || !this.enabled || !this.buffer.length) {
return;
}
this.clearTimers();
this.flush();
}
private restoreSession(): boolean {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (raw) {
const entry: SessionEntry = JSON.parse(raw);
const now = Date.now();
if (entry.id && (now - entry.ts) < SESSION_TIMEOUT_MS && (now - entry.st) < SESSION_MAX_AGE_MS) {
this.sessionId = entry.id;
this.startTime = entry.st;
return true;
}
}
} catch { }
return false;
}
private startNewSession(): void {
if (!this.enabled) {
return;
}
this.sessionId = crypto.randomUUID();
this.startTime = Date.now();
this.saveSession(this.sessionId);
const sessionEvent = { t: 's', td: 0, ...this.buildSessionPayload() };
fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ s: this.sessionId, events: [sessionEvent] }),
keepalive: true,
});
}
private saveSession(id: string): void {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify({ id, ts: Date.now(), st: this.startTime }));
} catch {
// failed to save session, ignore
}
}
private pushEvent(event: Record<string, unknown>): void {
if (!this.enabled) {
return;
}
if (Date.now() - this.startTime >= SESSION_MAX_AGE_MS) {
this.buffer.push({ t: 'x', td: Date.now() - this.startTime });
this.flush();
this.startNewSession();
}
event.td = Date.now() - this.startTime;
this.buffer.push(event);
this.scheduleFlush();
}
private buildSessionPayload(): Record<string, string> {
const payload: Record<string, string> = {};
payload.d = window.location.hostname;
const referrer = document.referrer;
if (referrer) {
payload.r = referrer;
}
const params = new URLSearchParams(window.location.search);
const utmMap: Record<string, string> = {
utm_source: 'us',
utm_medium: 'um',
utm_campaign: 'uc',
utm_term: 'ut',
utm_content: 'un',
};
for (const [param, key] of Object.entries(utmMap)) {
const val = params.get(param);
if (val) {
payload[key] = val;
}
}
return payload;
}
private scheduleFlush(): void {
if (this.idleTimer !== null) {
clearTimeout(this.idleTimer);
}
this.idleTimer = setTimeout(() => this.flush(), FLUSH_IDLE_MS);
if (this.maxTimer === null) {
this.maxTimer = setTimeout(() => this.flush(), FLUSH_MAX_MS);
}
}
private clearTimers(): void {
if (this.idleTimer !== null) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
if (this.maxTimer !== null) {
clearTimeout(this.maxTimer);
this.maxTimer = null;
}
}
private flush(): void {
if (!this.enabled || !this.buffer.length) {
return;
}
this.clearTimers();
const payload = this.flushPayload();
if (payload) {
fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
keepalive: true,
});
}
}
private flushPayload(): Record<string, unknown> | null {
if (!this.enabled) {
return null;
}
const events = this.buffer;
this.buffer = [];
if (events.length) {
return { s: this.sessionId, events, td: Date.now() - this.startTime };
} else {
return null;
}
}
private trackingAllowed(): boolean {
if (this.stateService.env.OFFICIAL_MEMPOOL_SPACE) {
return true;
}
const hostname = window.location.hostname;
if (hostname === 'mempool.space' || hostname.endsWith('.mempool.space')) {
return true;
}
if (this.stateService.env.customize?.enterprise) {
return true;
}
return false;
}
private doNotTrack(): boolean {
const dnt = (navigator as any).doNotTrack ?? (window as any).doNotTrack ?? (navigator as any).msDoNotTrack;
return dnt != null && (parseInt(dnt, 10) === 1 || dnt === 'yes');
}
private getRouteTemplate(): { path: string; network?: string } {
let route = this.router.routerState.snapshot.root;
const segments: string[] = [];
while (route) {
if (route.routeConfig?.path) {
segments.push(route.routeConfig.path);
}
route = route.firstChild;
}
const template = '/' + segments.join('/') || '/';
const network = this.stateService.network || undefined;
return { path: template, network };
}
}

View file

@ -92,6 +92,7 @@ export interface Env {
TWIDGET_API?: string;
customize?: Customization;
PROD_DOMAINS: string[];
ANALYTICS_URL?: string;
}
const defaultEnv: Env = {