From 11a266879b24d6c90fe405d636640dfc53553a18 Mon Sep 17 00:00:00 2001 From: mathias Date: Thu, 12 Mar 2026 07:51:36 +0100 Subject: [PATCH] =?UTF-8?q?openpanel=20service=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app.config.ts | 10 +- .../core/directives/openpanel.directive.ts | 58 +++++++ src/app/core/models/openpanel.model.ts | 35 ++++ src/app/core/provider/openpanel.provider.ts | 31 ++++ .../core/services/openpanel.service.spec.ts | 6 +- src/app/core/services/openpanel.service.ts | 159 +++++++++++++++--- .../navigation/navigation.component.html | 2 +- .../navigation/navigation.component.ts | 9 +- .../ui/nav-menu/nav-menu.component.html | 2 +- .../shared/ui/nav-menu/nav-menu.component.ts | 3 +- src/environments/openpanel.ts | 9 + 11 files changed, 296 insertions(+), 28 deletions(-) create mode 100644 src/app/core/directives/openpanel.directive.ts create mode 100644 src/app/core/models/openpanel.model.ts create mode 100644 src/app/core/provider/openpanel.provider.ts create mode 100644 src/environments/openpanel.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 67fb0b7..7152bfb 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,6 +1,8 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideNgIconsConfig } from '@ng-icons/core'; +import { environment } from '../environments/openpanel'; +import { provideOpenPanel } from '@core/provider/openpanel.provider'; import { routes } from './app.routes'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; @@ -10,6 +12,12 @@ export const appConfig: ApplicationConfig = { provideBrowserGlobalErrorListeners(), provideRouter(routes), provideClientHydration(withEventReplay()), - provideNgIconsConfig({}) + provideNgIconsConfig({}), + provideOpenPanel({ + clientId: environment.openPanel.clientId, + apiUrl: environment.openPanel.apiUrl, + trackScreenViews: true, + debug: !environment.production, + }) ] }; diff --git a/src/app/core/directives/openpanel.directive.ts b/src/app/core/directives/openpanel.directive.ts new file mode 100644 index 0000000..149893b --- /dev/null +++ b/src/app/core/directives/openpanel.directive.ts @@ -0,0 +1,58 @@ +import { Directive, HostListener, Input, inject } from '@angular/core'; +import { OpenPanelService, TrackProperties } from '@core/services/openpanel.service'; + +/** + * Directive for declarative event tracking directly in templates. + * + * @example + * + * + * @example + * Pricing + */ +@Directive({ + selector: '[opTrack]', + standalone: true, +}) +export class OpenPanelTrackDirective { + private readonly op = inject(OpenPanelService); + + /** The event name to track on click. */ + @Input({ required: true }) opTrack!: string; + + /** Optional properties to send with the event. */ + @Input() opTrackProps?: TrackProperties; + + /** Which DOM event triggers tracking. Default: 'click' */ + @Input() opTrackOn: 'click' | 'mouseenter' | 'focus' | 'blur' = 'click'; + + @HostListener('click') + onClick(): void { + if (this.opTrackOn === 'click') { + this.op.track(this.opTrack, this.opTrackProps); + } + } + + @HostListener('mouseenter') + onMouseEnter(): void { + if (this.opTrackOn === 'mouseenter') { + this.op.track(this.opTrack, this.opTrackProps); + } + } + + @HostListener('focus') + onFocus(): void { + if (this.opTrackOn === 'focus') { + this.op.track(this.opTrack, this.opTrackProps); + } + } + + @HostListener('blur') + onBlur(): void { + if (this.opTrackOn === 'blur') { + this.op.track(this.opTrack, this.opTrackProps); + } + } +} \ No newline at end of file diff --git a/src/app/core/models/openpanel.model.ts b/src/app/core/models/openpanel.model.ts new file mode 100644 index 0000000..216d402 --- /dev/null +++ b/src/app/core/models/openpanel.model.ts @@ -0,0 +1,35 @@ +import { InjectionToken } from '@angular/core'; + +export interface OpenPanelConfig { + /** Your OpenPanel Client ID (required) */ + clientId: string; + + /** URL of your OpenPanel API or self-hosted instance. + * Defaults to https://api.openpanel.dev */ + apiUrl?: string; + + /** Automatically track Angular Router navigation events as screen views. + * Default: true */ + trackScreenViews?: boolean; + + /** Track clicks on outgoing links automatically. + * Default: false */ + trackOutgoingLinks?: boolean; + + /** Enable declarative tracking via data-track HTML attributes. + * Default: false */ + trackAttributes?: boolean; + + /** Global properties sent with every event (e.g. app_version, environment). */ + globalProperties?: Record; + + /** Completely disable all tracking (e.g. in test environments). + * Default: false */ + disabled?: boolean; + + /** Enable verbose console logging for debugging. + * Default: false */ + debug?: boolean; +} + +export const OPENPANEL_CONFIG = new InjectionToken('OPENPANEL_CONFIG'); \ No newline at end of file diff --git a/src/app/core/provider/openpanel.provider.ts b/src/app/core/provider/openpanel.provider.ts new file mode 100644 index 0000000..d5b4db5 --- /dev/null +++ b/src/app/core/provider/openpanel.provider.ts @@ -0,0 +1,31 @@ +import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; +import { OpenPanelConfig, OPENPANEL_CONFIG } from '@core/models/openpanel.model'; +import { OpenPanelService } from '@core/services/openpanel.service'; + +/** + * Provides the OpenPanel analytics service for your Angular application. + * + * @example + * // app.config.ts + * import { provideOpenPanel } from './openpanel/openpanel.provider'; + * + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideRouter(routes), + * provideOpenPanel({ + * clientId: 'your-client-id', + * trackScreenViews: true, + * globalProperties: { + * app_version: '1.0.0', + * environment: 'production', + * }, + * }), + * ], + * }; + */ +export function provideOpenPanel(config: OpenPanelConfig): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: OPENPANEL_CONFIG, useValue: config }, + OpenPanelService, + ]); +} \ No newline at end of file diff --git a/src/app/core/services/openpanel.service.spec.ts b/src/app/core/services/openpanel.service.spec.ts index f15e083..eb3e954 100644 --- a/src/app/core/services/openpanel.service.spec.ts +++ b/src/app/core/services/openpanel.service.spec.ts @@ -1,13 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { OpenpanelService } from './openpanel.service'; +import { OpenPanelService } from './openpanel.service'; describe('OpenpanelService', () => { - let service: OpenpanelService; + let service: OpenPanelService; beforeEach(() => { TestBed.configureTestingModule({}); - service = TestBed.inject(OpenpanelService); + service = TestBed.inject(OpenPanelService); }); it('should be created', () => { diff --git a/src/app/core/services/openpanel.service.ts b/src/app/core/services/openpanel.service.ts index a6b1518..9b7ffe1 100644 --- a/src/app/core/services/openpanel.service.ts +++ b/src/app/core/services/openpanel.service.ts @@ -1,29 +1,148 @@ -import { Injectable } from '@angular/core'; +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { Router, NavigationEnd } from '@angular/router'; +import { isPlatformBrowser } from '@angular/common'; +import { PLATFORM_ID } from '@angular/core'; +import { filter, Subscription, skip } from 'rxjs'; import { OpenPanel } from '@openpanel/web'; +import type { IdentifyPayload } from '@openpanel/web'; +import { OpenPanelConfig, OPENPANEL_CONFIG } from '@core/models/openpanel.model'; + +export type TrackProperties = Record; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) -export class AnalyticsService { - private openpanel: OpenPanel; +export class OpenPanelService implements OnDestroy { + private readonly config = inject(OPENPANEL_CONFIG); + private readonly platformId = inject(PLATFORM_ID) + private readonly router = inject(Router); + + private op?: OpenPanel; + private routerSubscription?: Subscription; constructor() { - this.openpanel = new OpenPanel({ - clientId: 'DEINE_CLIENT_ID', - trackScreenViews: true, - trackOutgoingLinks: true, - }); - } - - trackEvent(name: string, properties?: Record) { - this.openpanel.track(name, properties); - } - - identifyUser(userId: string, traits?: Record) { - this.openpanel.identify(userId); - - if (traits) { - this.openpanel.setProfile(traits); + if(isPlatformBrowser(this.platformId)) { + this.initialize(); } } + + // ─── Initialization ──────────────────────────────────────────────────────── + + private initialize(): void { + this.op = new OpenPanel({ + clientId: this.config.clientId, + apiUrl: this.config.apiUrl, + trackScreenViews: false, // We handle this manually via Router + trackOutgoingLinks: this.config.trackOutgoingLinks ?? false, + trackAttributes: this.config.trackAttributes ?? false, + disabled: this.config.disabled ?? false, + }); + + if (this.config.globalProperties) { + this.op.setGlobalProperties(this.config.globalProperties); + } + + if (this.config.trackScreenViews !== false) { + this.setupRouteTracking(); + } + } + + private setupRouteTracking(): void { + // Nur einmalig subscriben, vorherige Sub zerstören + this.routerSubscription?.unsubscribe(); + + this.routerSubscription = this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + // Ersten initialNavigation-Event überspringen – SSR hat ihn schon getriggert + skip(1), + ) + .subscribe((event) => { + const navEvent = event as NavigationEnd; + this.trackScreenView(navEvent.urlAfterRedirects); + }); + } + + // ─── Public API ──────────────────────────────────────────────────────────── + + /** + * Tracks a custom event with optional properties. + * @example opService.track('button_clicked', { button_name: 'signup' }); + */ + track(eventName: string, properties?: TrackProperties): void { + if (!this.op) return; + if (this.config.debug) { + console.debug('[OpenPanel] track:', eventName, properties); + } + this.op.track(eventName, properties); + } + + /** + * Identifies the current user. Call this after login. + * @example opService.identify({ profileId: 'user-123', email: 'user@example.com' }); + */ + identify(payload: IdentifyPayload): void { + if (!this.op) return; + if (this.config.debug) { + console.debug('[OpenPanel] identify:', payload.profileId); + } + this.op.identify(payload); + } + + /** + * Clears the current user identity. Call this on logout. + */ + clearUser(): void { + if (!this.op) return; + if (this.config.debug) { + console.debug('[OpenPanel] clearUser'); + } + this.op.clear(); + } + + /** + * Sets properties that will be sent with every subsequent event. + */ + setGlobalProperties(properties: TrackProperties): void { + if (!this.op) return; + this.op.setGlobalProperties(properties); + } + + /** + * Increments a numeric property on the user profile. + * @example opService.increment('login_count'); + */ + increment(property: string): void { + if (!this.op) return; + this.op.increment(property); + } + + + + /** + * Decrements a numeric property on the user profile. + * @example opService.decrement('credits', 5); + */ + decrement(property: string): void { + if (!this.op) return; + this.op.decrement(property); + } + + /** + * Manually tracks a screen/page view. + */ + trackScreenView(path?: string): void { + if (!this.op) return; + const currentPath = path ?? this.router.url; + if (this.config.debug) { + console.debug('[OpenPanel] screenView:', currentPath); + } + this.op.track('screen_view', { path: currentPath }); + } + + // ─── Cleanup ─────────────────────────────────────────────────────────────── + + ngOnDestroy(): void { + this.routerSubscription?.unsubscribe(); + } } \ No newline at end of file diff --git a/src/app/features/landing/components/navigation/navigation.component.html b/src/app/features/landing/components/navigation/navigation.component.html index decf71b..2106f31 100644 --- a/src/app/features/landing/components/navigation/navigation.component.html +++ b/src/app/features/landing/components/navigation/navigation.component.html @@ -2,7 +2,7 @@
-

Hurler Webdesign

+

Hurler Webdesign

diff --git a/src/app/features/landing/components/navigation/navigation.component.ts b/src/app/features/landing/components/navigation/navigation.component.ts index 9f7d4ca..f646cfe 100644 --- a/src/app/features/landing/components/navigation/navigation.component.ts +++ b/src/app/features/landing/components/navigation/navigation.component.ts @@ -3,13 +3,20 @@ import { NgIcon } from '@ng-icons/core'; import { ToogleThemeComponent } from '@shared/utils/toogle-theme/toogle-theme.component'; import { NavMenuComponent } from '@shared/ui/nav-menu/nav-menu.component'; import { NavigationService } from '@core/services/navigation.service'; +import { OpenPanelService } from '@core/services/openpanel.service'; +import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; @Component({ selector: 'app-navigation', - imports: [NgIcon, ToogleThemeComponent, NavMenuComponent], + imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, OpenPanelTrackDirective], templateUrl: './navigation.component.html', styleUrl: './navigation.component.scss', }) export class NavigationComponent { protected readonly navigationService = inject(NavigationService); + private op = inject(OpenPanelService) + + onFeaturesClick(blindplan: string): void { + this.op.track('features_clicked', { blindplan }) + } } diff --git a/src/app/shared/ui/nav-menu/nav-menu.component.html b/src/app/shared/ui/nav-menu/nav-menu.component.html index 00bf826..7890e8e 100644 --- a/src/app/shared/ui/nav-menu/nav-menu.component.html +++ b/src/app/shared/ui/nav-menu/nav-menu.component.html @@ -4,7 +4,7 @@