mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
demo integration of matomo replacement
This commit is contained in:
parent
8a9904dbe6
commit
da8981018d
8 changed files with 332 additions and 1 deletions
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
283
frontend/src/app/services/analytics.service.ts
Normal file
283
frontend/src/app/services/analytics.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,7 @@ export interface Env {
|
|||
TWIDGET_API?: string;
|
||||
customize?: Customization;
|
||||
PROD_DOMAINS: string[];
|
||||
ANALYTICS_URL?: string;
|
||||
}
|
||||
|
||||
const defaultEnv: Env = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue