diff --git a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts index cf2595cb2..c8f815361 100644 --- a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts +++ b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts @@ -44,6 +44,7 @@ import { LinkifyPipe } from '@pipes/linkify.pipe'; import { MeetingTimePipe } from '@pipes/meeting-time.pipe'; import { RecurrenceSummaryPipe } from '@pipes/recurrence-summary.pipe'; import { MeetingService } from '@services/meeting.service'; +import { PlausibleService } from '@services/plausible.service'; import { ProjectContextService } from '@services/project-context.service'; import { ProjectService } from '@services/project.service'; import { UserService } from '@services/user.service'; @@ -115,6 +116,7 @@ export class MeetingJoinComponent implements OnInit { private readonly userService = inject(UserService); private readonly clipboard = inject(Clipboard); private readonly projectContextService = inject(ProjectContextService); + private readonly plausibleService = inject(PlausibleService); private readonly dialogService = inject(DialogService); private readonly destroyRef = inject(DestroyRef); private readonly platformId = inject(PLATFORM_ID); @@ -297,6 +299,7 @@ export class MeetingJoinComponent implements OnInit { this.registrants = this.initializeRegistrants(); this.parentProject = this.initializeParentProject(); this.initializeAutoJoin(); + this.initializePublicMeetingPageviewTracking(); } public ngOnInit(): void { @@ -1128,6 +1131,46 @@ export class MeetingJoinComponent implements OnInit { ); } + // PlausibleService defers the auto-pageview for /meetings/:id — fire it here once context lands. + private initializePublicMeetingPageviewTracking(): void { + if (isPlatformServer(this.platformId)) { + return; + } + // Caps the parent foundation fetch so ROOT_PROJECT_SLUG → null (mapped in initializeParentProject) + // doesn't block the pageview forever. Sub-projects whose parent doesn't land in time record + // project_* correctly and leave foundation_* empty (see buildPageviewContext callsite below). + const PARENT_FETCH_TIMEOUT_MS = 2000; + toObservable(this.project) + .pipe( + filter((p): p is Partial => !!p?.slug), + switchMap((project) => { + if (!project.parent_uid) { + return of({ project, parent: null as Project | null }); + } + return merge( + toObservable(this.parentProject).pipe( + filter((parent): parent is Project => !!parent), + map((parent) => ({ project, parent: parent as Project | null })) + ), + timer(PARENT_FETCH_TIMEOUT_MS).pipe(map(() => ({ project, parent: null as Project | null }))) + ); + }), + take(1), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ project, parent }) => { + const isTopLevel = !project.parent_uid; + this.plausibleService.trackPage( + PlausibleService.buildPageviewContext({ + foundationSlug: isTopLevel ? project.slug : parent?.slug, + foundationName: isTopLevel ? project.name : parent?.name, + projectSlug: isTopLevel ? undefined : project.slug, + projectName: isTopLevel ? undefined : project.name, + }) + ); + }); + } + private initializeRegistrants(): Signal { return toSignal( combineLatest([ diff --git a/apps/lfx-one/src/app/shared/services/plausible.service.ts b/apps/lfx-one/src/app/shared/services/plausible.service.ts index 4f8102f3a..d804c790e 100644 --- a/apps/lfx-one/src/app/shared/services/plausible.service.ts +++ b/apps/lfx-one/src/app/shared/services/plausible.service.ts @@ -6,9 +6,12 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router } from '@angular/router'; import { environment } from '@environments/environment'; import { PLAUSIBLE_DOMAIN, PLAUSIBLE_SRC } from '@lfx-one/shared/constants'; -import { PlausibleCall } from '@lfx-one/shared/interfaces'; +import { PlausibleCall, PlausiblePageviewContext } from '@lfx-one/shared/interfaces'; import { filter } from 'rxjs'; +import { LensService } from './lens.service'; +import { ProjectContextService } from './project-context.service'; + /** * Plausible analytics service for privacy-friendly page and event tracking. * Uses Angular's afterNextRender for SSR-safe script loading. @@ -19,6 +22,11 @@ import { filter } from 'rxjs'; export class PlausibleService { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + private readonly projectContextService = inject(ProjectContextService); + private readonly lensService = inject(LensService); + + // Routes whose owning component fires `trackPage()` itself once async context resolves. + private static readonly deferredPageviewPattern = /^\/meetings\/\d+(-\d{13})?$/; private scriptLoaded = false; private analyticsReady = false; @@ -47,22 +55,43 @@ export class PlausibleService { }); } - /** - * Track a page view - * @param properties Optional page properties - */ - public trackPage(properties?: Record): void { + // Auto-prepends sanitized path/url/title — callers only supply context. + public trackPage(context?: PlausiblePageviewContext): void { if (typeof window === 'undefined' || this.impersonating || !this.analyticsReady || !window.plausible) { return; } try { - window.plausible('pageview', { u: this.getSanitizedUrl(), props: properties }); + const url = this.getSanitizedUrl(); + const props: Record = { + path: this.getSanitizedPath(window.location.pathname), + url, + title: typeof document !== 'undefined' ? document.title : undefined, + ...(context as Record | undefined), + }; + window.plausible('pageview', { u: url, props }); } catch (error) { console.error('Error tracking page with Plausible:', error); } } + // Single owner of the pageview custom-prop schema — keep new dimensions here, not at callsites. + public static buildPageviewContext(input: { + foundationSlug?: string | null; + foundationName?: string | null; + projectSlug?: string | null; + projectName?: string | null; + lens?: string | null; + }): PlausiblePageviewContext { + const context: PlausiblePageviewContext = {}; + if (input.foundationSlug) context.foundation = input.foundationSlug; + if (input.foundationName) context.foundation_name = input.foundationName; + if (input.projectSlug) context.project = input.projectSlug; + if (input.projectName) context.project_name = input.projectName; + if (input.lens) context.lens = input.lens; + return context; + } + /** * Track a custom event * @param eventName Event name @@ -117,7 +146,10 @@ export class PlausibleService { // only after the bundle has executed and replaced the queue stub. script.onload = () => { this.analyticsReady = true; - this.trackPage(); + if (PlausibleService.deferredPageviewPattern.test(window.location.pathname)) { + return; + } + this.trackPage(this.buildContextProps()); }; script.onerror = (error) => { @@ -149,14 +181,27 @@ export class PlausibleService { if (typeof document === 'undefined') { return; } - this.trackPage({ - path: this.getSanitizedPath(event.urlAfterRedirects), - url: this.getSanitizedUrl(), - title: document.title, - }); + const path = this.getSanitizedPath(event.urlAfterRedirects); + if (PlausibleService.deferredPageviewPattern.test(path)) { + return; + } + this.trackPage(this.buildContextProps()); }); } + // Foundation/project read independently (not via activeContext) so the project lens emits both. + private buildContextProps(): PlausiblePageviewContext { + const foundation = this.projectContextService.selectedFoundation(); + const project = this.projectContextService.selectedProject(); + return PlausibleService.buildPageviewContext({ + foundationSlug: foundation?.slug, + foundationName: foundation?.name, + projectSlug: project?.slug, + projectName: project?.name, + lens: this.lensService.activeLens(), + }); + } + /** * Build a privacy-safe URL string for Plausible. * Strips query params and hash to avoid leaking auth tokens, OTPs, or diff --git a/packages/shared/src/interfaces/plausible.interface.ts b/packages/shared/src/interfaces/plausible.interface.ts index c8b27c9a7..d400dab70 100644 --- a/packages/shared/src/interfaces/plausible.interface.ts +++ b/packages/shared/src/interfaces/plausible.interface.ts @@ -13,6 +13,15 @@ export interface PlausibleConfig { enabled: boolean; } +// Custom-prop schema attached to Plausible pageviews — keys omitted when unknown. +export interface PlausiblePageviewContext { + foundation?: string; + foundation_name?: string; + project?: string; + project_name?: string; + lens?: string; +} + /** * One queued Plausible call captured by the upstream snippet's queue stub * before the real script loads. Each entry is the full argument tuple the