mathias revised this gist 4 days ago. Go to revision
No changes
mathias revised this gist 6 days ago. Go to revision
4 files changed, 250 insertions
environment.ts(file created)
| @@ -0,0 +1,7 @@ | |||
| 1 | + | export const environment = { | |
| 2 | + | production: false, | |
| 3 | + | openPanel: { | |
| 4 | + | clientId: 'OPENPANEL_CLIENT_ID', | |
| 5 | + | apiUrl: 'https://worker.domain.de', // mapping zu Worker Container, apiURL nur bei selfhost notwendig | |
| 6 | + | } | |
| 7 | + | }; | |
openpanel.directive.ts(file created)
| @@ -0,0 +1,58 @@ | |||
| 1 | + | import { Directive, HostListener, Input, inject } from '@angular/core'; | |
| 2 | + | import { OpenPanelService, TrackProperties } from '@core/services/openpanel.service'; | |
| 3 | + | ||
| 4 | + | /** | |
| 5 | + | * Directive for declarative event tracking directly in templates. | |
| 6 | + | * | |
| 7 | + | * @example | |
| 8 | + | * <button opTrack="signup_clicked" [opTrackProps]="{ location: 'hero' }"> | |
| 9 | + | * Sign Up | |
| 10 | + | * </button> | |
| 11 | + | * | |
| 12 | + | * @example | |
| 13 | + | * <a routerLink="/pricing" opTrack="pricing_link_clicked">Pricing</a> | |
| 14 | + | */ | |
| 15 | + | @Directive({ | |
| 16 | + | selector: '[opTrack]', | |
| 17 | + | standalone: true, | |
| 18 | + | }) | |
| 19 | + | export class OpenPanelTrackDirective { | |
| 20 | + | private readonly op = inject(OpenPanelService); | |
| 21 | + | ||
| 22 | + | /** The event name to track on click. */ | |
| 23 | + | @Input({ required: true }) opTrack!: string; | |
| 24 | + | ||
| 25 | + | /** Optional properties to send with the event. */ | |
| 26 | + | @Input() opTrackProps?: TrackProperties; | |
| 27 | + | ||
| 28 | + | /** Which DOM event triggers tracking. Default: 'click' */ | |
| 29 | + | @Input() opTrackOn: 'click' | 'mouseenter' | 'focus' | 'blur' = 'click'; | |
| 30 | + | ||
| 31 | + | @HostListener('click') | |
| 32 | + | onClick(): void { | |
| 33 | + | if (this.opTrackOn === 'click') { | |
| 34 | + | this.op.track(this.opTrack, this.opTrackProps); | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | @HostListener('mouseenter') | |
| 39 | + | onMouseEnter(): void { | |
| 40 | + | if (this.opTrackOn === 'mouseenter') { | |
| 41 | + | this.op.track(this.opTrack, this.opTrackProps); | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | @HostListener('focus') | |
| 46 | + | onFocus(): void { | |
| 47 | + | if (this.opTrackOn === 'focus') { | |
| 48 | + | this.op.track(this.opTrack, this.opTrackProps); | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | @HostListener('blur') | |
| 53 | + | onBlur(): void { | |
| 54 | + | if (this.opTrackOn === 'blur') { | |
| 55 | + | this.op.track(this.opTrack, this.opTrackProps); | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
openpanel.model.ts(file created)
| @@ -0,0 +1,35 @@ | |||
| 1 | + | import { InjectionToken } from '@angular/core'; | |
| 2 | + | ||
| 3 | + | export interface OpenPanelConfig { | |
| 4 | + | /** Your OpenPanel Client ID (required) */ | |
| 5 | + | clientId: string; | |
| 6 | + | ||
| 7 | + | /** URL of your OpenPanel API or self-hosted instance. | |
| 8 | + | * Defaults to https://api.openpanel.dev */ | |
| 9 | + | apiUrl?: string; | |
| 10 | + | ||
| 11 | + | /** Automatically track Angular Router navigation events as screen views. | |
| 12 | + | * Default: true */ | |
| 13 | + | trackScreenViews?: boolean; | |
| 14 | + | ||
| 15 | + | /** Track clicks on outgoing links automatically. | |
| 16 | + | * Default: false */ | |
| 17 | + | trackOutgoingLinks?: boolean; | |
| 18 | + | ||
| 19 | + | /** Enable declarative tracking via data-track HTML attributes. | |
| 20 | + | * Default: false */ | |
| 21 | + | trackAttributes?: boolean; | |
| 22 | + | ||
| 23 | + | /** Global properties sent with every event (e.g. app_version, environment). */ | |
| 24 | + | globalProperties?: Record<string, string | number | boolean>; | |
| 25 | + | ||
| 26 | + | /** Completely disable all tracking (e.g. in test environments). | |
| 27 | + | * Default: false */ | |
| 28 | + | disabled?: boolean; | |
| 29 | + | ||
| 30 | + | /** Enable verbose console logging for debugging. | |
| 31 | + | * Default: false */ | |
| 32 | + | debug?: boolean; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | export const OPENPANEL_CONFIG = new InjectionToken<OpenPanelConfig>('OPENPANEL_CONFIG'); | |
openpanel.service.ts(file created)
| @@ -0,0 +1,150 @@ | |||
| 1 | + | import { Injectable, OnDestroy, inject } from '@angular/core'; | |
| 2 | + | import { Router, NavigationEnd } from '@angular/router'; | |
| 3 | + | import { isPlatformBrowser } from '@angular/common'; | |
| 4 | + | import { PLATFORM_ID } from '@angular/core'; | |
| 5 | + | import { filter, Subscription, skip } from 'rxjs'; | |
| 6 | + | import { OpenPanel } from '@openpanel/web'; | |
| 7 | + | import type { IdentifyPayload } from '@openpanel/web'; | |
| 8 | + | import { OpenPanelConfig, OPENPANEL_CONFIG } from '@core/models/openpanel.model'; | |
| 9 | + | ||
| 10 | + | export type TrackProperties = Record<string, string | number | boolean | null | undefined>; | |
| 11 | + | ||
| 12 | + | @Injectable({ | |
| 13 | + | providedIn: 'root', | |
| 14 | + | }) | |
| 15 | + | export class OpenPanelService implements OnDestroy { | |
| 16 | + | private readonly config = inject(OPENPANEL_CONFIG); | |
| 17 | + | private readonly platformId = inject(PLATFORM_ID) | |
| 18 | + | private readonly router = inject(Router); | |
| 19 | + | ||
| 20 | + | private op?: OpenPanel; | |
| 21 | + | private routerSubscription?: Subscription; | |
| 22 | + | ||
| 23 | + | constructor() { | |
| 24 | + | if(isPlatformBrowser(this.platformId)) { | |
| 25 | + | this.initialize(); | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // ─── Initialization ──────────────────────────────────────────────────────── | |
| 30 | + | ||
| 31 | + | private initialize(): void { | |
| 32 | + | this.op = new OpenPanel({ | |
| 33 | + | clientId: this.config.clientId, | |
| 34 | + | apiUrl: this.config.apiUrl, | |
| 35 | + | trackScreenViews: false, // We handle this manually via Router | |
| 36 | + | trackOutgoingLinks: this.config.trackOutgoingLinks ?? false, | |
| 37 | + | trackAttributes: this.config.trackAttributes ?? false, | |
| 38 | + | disabled: this.config.disabled ?? false, | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | if (this.config.globalProperties) { | |
| 42 | + | this.op.setGlobalProperties(this.config.globalProperties); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | if (this.config.trackScreenViews !== false) { | |
| 46 | + | this.setupRouteTracking(); | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | private setupRouteTracking(): void { | |
| 51 | + | this.routerSubscription?.unsubscribe(); | |
| 52 | + | ||
| 53 | + | this.routerSubscription = this.router.events.pipe( | |
| 54 | + | filter((event) => event instanceof NavigationEnd), | |
| 55 | + | ).subscribe(() => { | |
| 56 | + | const route = this.getActiveRoute(); | |
| 57 | + | const trackName = route.snapshot.data['trackName'] ?? this.router.url; | |
| 58 | + | this.trackScreenView(trackName); | |
| 59 | + | }); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | private getActiveRoute() { | |
| 63 | + | let route = this.router.routerState.root; | |
| 64 | + | while (route.firstChild) route = route.firstChild; | |
| 65 | + | return route; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // ─── Public API ──────────────────────────────────────────────────────────── | |
| 69 | + | ||
| 70 | + | /** | |
| 71 | + | * Tracks a custom event with optional properties. | |
| 72 | + | * @example opService.track('button_clicked', { button_name: 'signup' }); | |
| 73 | + | */ | |
| 74 | + | track(eventName: string, properties?: TrackProperties): void { | |
| 75 | + | if (!this.op) return; | |
| 76 | + | if (this.config.debug) { | |
| 77 | + | console.debug('[OpenPanel] track:', eventName, properties); | |
| 78 | + | } | |
| 79 | + | this.op.track(eventName, properties); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /** | |
| 83 | + | * Identifies the current user. Call this after login. | |
| 84 | + | * @example opService.identify({ profileId: 'user-123', email: 'user@example.com' }); | |
| 85 | + | */ | |
| 86 | + | identify(payload: IdentifyPayload): void { | |
| 87 | + | if (!this.op) return; | |
| 88 | + | if (this.config.debug) { | |
| 89 | + | console.debug('[OpenPanel] identify:', payload.profileId); | |
| 90 | + | } | |
| 91 | + | this.op.identify(payload); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** | |
| 95 | + | * Clears the current user identity. Call this on logout. | |
| 96 | + | */ | |
| 97 | + | clearUser(): void { | |
| 98 | + | if (!this.op) return; | |
| 99 | + | if (this.config.debug) { | |
| 100 | + | console.debug('[OpenPanel] clearUser'); | |
| 101 | + | } | |
| 102 | + | this.op.clear(); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /** | |
| 106 | + | * Sets properties that will be sent with every subsequent event. | |
| 107 | + | */ | |
| 108 | + | setGlobalProperties(properties: TrackProperties): void { | |
| 109 | + | if (!this.op) return; | |
| 110 | + | this.op.setGlobalProperties(properties); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /** | |
| 114 | + | * Increments a numeric property on the user profile. | |
| 115 | + | * @example opService.increment('login_count'); | |
| 116 | + | */ | |
| 117 | + | increment(property: string): void { | |
| 118 | + | if (!this.op) return; | |
| 119 | + | this.op.increment(property); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | ||
| 123 | + | ||
| 124 | + | /** | |
| 125 | + | * Decrements a numeric property on the user profile. | |
| 126 | + | * @example opService.decrement('credits'); | |
| 127 | + | */ | |
| 128 | + | decrement(property: string): void { | |
| 129 | + | if (!this.op) return; | |
| 130 | + | this.op.decrement(property); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /** | |
| 134 | + | * Manually tracks a screen/page view. | |
| 135 | + | */ | |
| 136 | + | trackScreenView(path?: string): void { | |
| 137 | + | if (!this.op) return; | |
| 138 | + | const currentPath = path ?? this.router.url; | |
| 139 | + | if (this.config.debug) { | |
| 140 | + | console.debug('[OpenPanel] screenView:', currentPath); | |
| 141 | + | } | |
| 142 | + | this.op.track('screen_view', { path: currentPath }); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // ─── Cleanup ─────────────────────────────────────────────────────────────── | |
| 146 | + | ||
| 147 | + | ngOnDestroy(): void { | |
| 148 | + | this.routerSubscription?.unsubscribe(); | |
| 149 | + | } | |
| 150 | + | } | |