diff --git a/apps/lfx-one/e2e/org-project-detail.spec.ts b/apps/lfx-one/e2e/org-project-detail.spec.ts
new file mode 100644
index 000000000..0187d934c
--- /dev/null
+++ b/apps/lfx-one/e2e/org-project-detail.spec.ts
@@ -0,0 +1,131 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+/**
+ * Org Lens · Project Detail Page E2E Tests (LFXV2-1885)
+ *
+ * Covers the acceptance criteria for the per-project Org Lens view:
+ * - data-testid resolution smoke test (breadcrumb, hero, tabs, card groups, trend)
+ * - tab strip switching (click + keyboard) with ?tab= URL persistence
+ * - leaderboard ranking, score/metric toggles with ?metric= persistence,
+ * Activity Count hiding the band tags, and Show more pagination
+ * - not-found (404) panel for an unknown slug
+ *
+ * Prerequisites:
+ * - Dev server running on the Playwright baseURL
+ * - User authenticated with the `org-lens-enabled` flag on and an organization selected
+ *
+ * Demo semantics (v1): the page is served from server-side demo fixtures
+ * (server/services/org-lens-project-detail.demo-data.ts). `kubernetes` is a rich seeded project; an
+ * unknown slug returns null → the not-found panel.
+ */
+
+import { expect, test } from '@playwright/test';
+
+const DETAIL_URL = '/org/projects/kubernetes';
+const DETAIL_URL_BOGUS = '/org/projects/totally-bogus-project';
+const DATA_LOAD_TIMEOUT = 30_000;
+
+test.setTimeout(90_000);
+
+test.describe('Org Project Detail — testid resolution', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(DETAIL_URL, { waitUntil: 'domcontentloaded' });
+ await expect(page).not.toHaveURL(/auth0\.com/);
+ await expect(page.getByTestId('project-detail-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
+ });
+
+ test('renders breadcrumb, hero and tab strip', async ({ page }) => {
+ await expect(page.getByTestId('project-detail-breadcrumb')).toBeVisible();
+ await expect(page.getByTestId('project-detail-hero')).toBeVisible();
+ await expect(page.getByTestId('project-detail-name')).toHaveText('Kubernetes');
+ await expect(page.getByTestId('project-detail-first-commit')).toBeVisible();
+ await expect(page.getByTestId('project-detail-software-value')).toBeVisible();
+ await expect(page.getByTestId('project-detail-health-badge')).toBeVisible();
+ await expect(page.getByTestId('project-detail-foundation-pill')).toBeVisible();
+ await expect(page.getByTestId('project-detail-tab-pd-influence')).toBeVisible();
+ await expect(page.getByTestId('project-detail-tab-pd-leaderboards')).toBeVisible();
+ });
+
+ test('renders the Technical and Ecosystem card groups', async ({ page }) => {
+ for (const key of ['maintainers', 'contributors', 'commits', 'pull-requests']) {
+ await expect(page.getByTestId(`project-detail-technical-card-${key}`)).toBeVisible();
+ }
+ for (const key of ['collaboration', 'meeting-attendance', 'board-members', 'committee-members']) {
+ await expect(page.getByTestId(`project-detail-ecosystem-card-${key}`)).toBeVisible();
+ }
+
+ await page.getByTestId('project-detail-tab-pd-leaderboards').click();
+ await expect(page.getByTestId('project-detail-trend-group')).toBeVisible();
+ });
+});
+
+test.describe('Org Project Detail — tab strip', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(DETAIL_URL, { waitUntil: 'domcontentloaded' });
+ await expect(page.getByTestId('project-detail-page')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
+ });
+
+ test('defaults to Our Influence and switches to Leaderboards via click + URL', async ({ page }) => {
+ await expect(page.getByTestId('project-detail-technical-group')).toBeVisible();
+ await page.getByTestId('project-detail-tab-pd-leaderboards').click();
+ await expect(page).toHaveURL(/tab=pd-leaderboards/);
+ await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible();
+ });
+
+ test('deep-links to the Leaderboards tab via ?tab=', async ({ page }) => {
+ await page.goto(`${DETAIL_URL}?tab=pd-leaderboards`, { waitUntil: 'domcontentloaded' });
+ await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
+ });
+
+ test('arrow keys move between tabs', async ({ page }) => {
+ await page.getByTestId('project-detail-tab-pd-influence').focus();
+ await page.keyboard.press('ArrowRight');
+ await expect(page.getByTestId('project-detail-tab-pd-leaderboards')).toBeFocused();
+ });
+});
+
+test.describe('Org Project Detail — leaderboards', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(`${DETAIL_URL}?tab=pd-leaderboards`, { waitUntil: 'domcontentloaded' });
+ await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
+ });
+
+ test('renders both side-by-side boards with the viewing-org row pinned', async ({ page }) => {
+ await expect(page.getByTestId('project-detail-leaderboard-technical')).toBeVisible();
+ await expect(page.getByTestId('project-detail-leaderboard-ecosystem')).toBeVisible();
+ await expect(page.getByTestId('project-detail-leaderboard-technical-viewing-row')).toBeVisible();
+ await expect(page.getByTestId('project-detail-leaderboard-ecosystem-viewing-row')).toBeVisible();
+ await expect(page.getByTestId('project-detail-trend-group')).toBeVisible();
+ });
+
+ test('metric toggle persists in the URL and switches the score column', async ({ page }) => {
+ await expect(page.getByRole('columnheader', { name: 'Influence Score' }).first()).toBeVisible();
+ await page.getByTestId('project-detail-metric-activity').click();
+ await expect(page).toHaveURL(/metric=activity/);
+ await expect(page.getByRole('columnheader', { name: 'Activity (12mo)' }).first()).toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'Influence Score' })).toHaveCount(0);
+ });
+
+ test('time-range toggle persists in the URL and updates the activity column label', async ({ page }) => {
+ await page.getByTestId('project-detail-metric-activity').click();
+ await page.getByTestId('project-detail-time-range-2y').click();
+ await expect(page).toHaveURL(/range=2y/);
+ await expect(page.getByRole('columnheader', { name: 'Activity (24mo)' }).first()).toBeVisible();
+ });
+
+ test('search filters a board to matching organizations', async ({ page }) => {
+ await page.locator('[data-test="project-detail-search-technical"]').fill('Google');
+ const rows = page.locator('[data-testid="project-detail-leaderboard-technical"] tbody tr');
+ await expect(rows).toHaveCount(1);
+ await expect(rows.first()).toContainText('Google');
+ });
+});
+
+test.describe('Org Project Detail — not found', () => {
+ test('renders the 404 panel for an unknown slug', async ({ page }) => {
+ await page.goto(DETAIL_URL_BOGUS, { waitUntil: 'domcontentloaded' });
+ await expect(page).not.toHaveURL(/auth0\.com/);
+ await expect(page.getByTestId('project-detail-not-found')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
+ });
+});
diff --git a/apps/lfx-one/src/app/app.routes.ts b/apps/lfx-one/src/app/app.routes.ts
index 3938f9cb6..5a8da16a0 100644
--- a/apps/lfx-one/src/app/app.routes.ts
+++ b/apps/lfx-one/src/app/app.routes.ts
@@ -113,6 +113,16 @@ export const routes: Routes = [
data: { lens: 'org', title: 'Projects', description: 'Projects your organization participates in.', icon: 'fa-light fa-folder' },
loadComponent: () => import('./modules/dashboards/org/org-projects/org-projects.component').then((m) => m.OrgProjectsComponent),
},
+ {
+ path: 'projects/:projectSlug',
+ data: {
+ lens: 'org',
+ title: 'Project Detail',
+ description: "Your organization's involvement and competitive standing on a project.",
+ icon: 'fa-light fa-folder',
+ },
+ loadComponent: () => import('./modules/dashboards/org/org-project-detail/org-project-detail.component').then((m) => m.OrgProjectDetailComponent),
+ },
{
// INFO: Future Epic implementation — the ROI page is hidden; deep links fall
// back to the org overview until the org ROI feature is built.
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html
index df1a5e24d..feb70e3a6 100644
--- a/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html
+++ b/apps/lfx-one/src/app/modules/dashboards/org/components/org-overview-foundations-and-projects/org-overview-foundations-and-projects.component.html
@@ -91,11 +91,14 @@
@for (project of row.projects; track project.projectId) {
-
>({});
@@ -115,9 +116,27 @@ export class OrgOverviewFoundationsAndProjectsComponent {
});
}
- // INFO: Future Epic implementation — project-row click/keydown handlers and their
- // `mfp_project_row_click` telemetry were removed with the hidden /org/projects drilldown.
- // Restore them alongside the row's interactive affordances when the drilldown is built.
+ protected onProjectClick(payload: { projectId: string; projectName: string }): void {
+ const orgId = this.accountContextService.selectedAccount().accountId;
+ this.plausibleService.trackEvent('mfp_project_row_click', {
+ orgId,
+ projectId: payload.projectId,
+ projectName: payload.projectName,
+ });
+ }
+
+ protected onProjectRowClick(project: OrgLensFoundationRow['projects'][number]): void {
+ if (!project.isLfProject) return;
+ this.onProjectClick({ projectId: project.projectId, projectName: project.projectName });
+ void this.router.navigate(['/org/projects', project.projectSlug || project.projectId]);
+ }
+
+ protected onProjectRowKeydown(event: KeyboardEvent, project: OrgLensFoundationRow['projects'][number]): void {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ if (!project.isLfProject) return;
+ event.preventDefault();
+ this.onProjectRowClick(project);
+ }
private emitOverviewViewOnce(orgUid: string): void {
if (this.viewedOrgs.has(orgUid)) return;
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html
new file mode 100644
index 000000000..eb9dbc087
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+ @for (tab of tabs(); track tab.id) {
+
+ }
+
+
+
+
+ @if (metricOptions(); as opts) {
+
+ @for (opt of opts; track opt.id) {
+
+ }
+
+ }
+
+ @for (opt of timeRangeOptions(); track opt.id) {
+
+ }
+
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts
new file mode 100644
index 000000000..2da3bd30e
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail-tab-bar.component.ts
@@ -0,0 +1,49 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { isPlatformBrowser } from '@angular/common';
+import { Component, ElementRef, inject, input, output, PLATFORM_ID, viewChildren } from '@angular/core';
+import type { OrgLensLeaderboardMetric, OrgLensLeaderboardTimeRange, OrgLensProjectDetailTab } from '@lfx-one/shared/interfaces';
+
+@Component({
+ selector: 'lfx-org-project-detail-tab-bar',
+ host: {
+ class: 'flex w-full items-center justify-between gap-4 rounded-xl border border-gray-200 bg-white px-4 py-2.5 shadow-sm',
+ 'data-testid': 'project-detail-controls-bar',
+ },
+ templateUrl: './org-project-detail-tab-bar.component.html',
+})
+export class OrgProjectDetailTabBarComponent {
+ private readonly tabBtns = viewChildren>('tabBtn');
+
+ private readonly platformId = inject(PLATFORM_ID);
+
+ public readonly tabs = input.required<{ id: OrgLensProjectDetailTab; label: string }[]>();
+ public readonly activeTab = input.required();
+ public readonly timeRange = input.required();
+ public readonly timeRangeOptions = input.required<{ id: OrgLensLeaderboardTimeRange; label: string }[]>();
+ /** When provided, renders the metric toggle between the tab pills and the time dropdown. */
+ public readonly metric = input(null);
+ public readonly metricOptions = input<{ id: OrgLensLeaderboardMetric; label: string; icon: string }[] | null>(null);
+ public readonly tabChange = output();
+ public readonly timeRangeChange = output();
+ public readonly metricChange = output();
+
+ protected onTabKeydown(event: KeyboardEvent): void {
+ const ids = this.tabs().map((t) => t.id);
+ const idx = ids.indexOf(this.activeTab());
+ let next: number | null = null;
+ if (event.key === 'ArrowRight') next = (idx + 1) % ids.length;
+ else if (event.key === 'ArrowLeft') next = (idx - 1 + ids.length) % ids.length;
+ else if (event.key === 'Home') next = 0;
+ else if (event.key === 'End') next = ids.length - 1;
+ if (next !== null) {
+ event.preventDefault();
+ this.tabChange.emit(ids[next]);
+ if (isPlatformBrowser(this.platformId)) {
+ const n = next;
+ setTimeout(() => this.tabBtns()[n]?.nativeElement.focus());
+ }
+ }
+ }
+}
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html
new file mode 100644
index 000000000..f55f72bcf
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.html
@@ -0,0 +1,547 @@
+
+
+
+
+ @if (!hasCompany()) {
+
+ } @else {
+ @switch (pageState()) {
+ @case ('loading') {
+
+ }
+
+ @case ('error') {
+
+
+ }
+
+ @case ('notFound') {
+
+
+ }
+
+ @default {
+ @if (hero(); as h) {
+
+
+
+
+
+
+
+
+
+ @if (h.logoUrl) {
+
![]()
+ } @else {
+
+ {{ logoInitials() }}
+
+ }
+
{{ h.projectName }}
+
+
+ {{ h.description }}
+ @if (h.lfxInsightsUrl) {
+ Source:
+
+ LFX Insights
+
+
+ }
+
+
+
+
+
+
+ First commit
+ {{ firstCommitLabel() }}
+
+
+
+ Software value
+
+
+ {{ softwareValueLabel() }}
+
+
+ Health score
+ @if (healthMeta(); as hm) {
+
+
+
+ }
+
+
+ Foundation
+ {{ h.foundationLabel }}
+
+
+
+
+
+
+
+
+
+ @switch (activeTab()) {
+ @case ('pd-influence') {
+
+
+
+
+
Our Technical Influence
+ @if (technicalBandMeta(); as meta) {
+
+
+ {{ meta.label }}
+
+ }
+
+
+
+
+
+
+
+
+ @for (card of technicalCards(); track card.key) {
+
+ }
+
+
+
+
+
+
+
+
Our Ecosystem Influence
+ @if (ecosystemBandMeta(); as meta) {
+
+
+ {{ meta.label }}
+
+ }
+
+
+
+
+
+
+
+
+ @for (card of ecosystemCards(); track card.key) {
+
+ }
+
+
+
+
+ }
+ @case ('pd-leaderboards') {
+
+
+
+
+
+
+
+
Technical Influence Leaderboard
+
+
+
+
+
+
+ @if (technicalBoard().length === 0) {
+
+ {{ techSearchHasQuery() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }}
+
+ } @else {
+
+
+
+ | # |
+ Organization |
+ {{ scoreColumnLabel() }} |
+
+
+
+
+ | {{ row.rank }} |
+
+
+ @if (row.orgLogoUrl) {
+ ![]()
+ } @else {
+ {{ row.initials }}
+ }
+ {{ row.orgName }}
+ @if (row.isViewingOrg) {
+ You
+ }
+
+ |
+
+ @if (isActivityMode()) {
+ {{ row.activityLabel }}
+ } @else {
+
+ }
+ |
+
+
+
+ }
+
+
+
+
+
+
+
Ecosystem Influence Leaderboard
+
+
+
+
+
+
+ @if (ecosystemBoard().length === 0) {
+
+ {{ ecoSearchHasQuery() ? 'No organizations match your search.' : 'No ranked organizations for this project yet.' }}
+
+ } @else {
+
+
+
+ | # |
+ Organization |
+ {{ scoreColumnLabel() }} |
+
+
+
+
+ | {{ row.rank }} |
+
+
+ @if (row.orgLogoUrl) {
+ ![]()
+ } @else {
+ {{ row.initials }}
+ }
+ {{ row.orgName }}
+ @if (row.isViewingOrg) {
+ You
+ }
+
+ |
+
+ @if (isActivityMode()) {
+ {{ row.activityLabel }}
+ } @else {
+
+ }
+ |
+
+
+
+ }
+
+
+
+
+
+
+
+ @if (hasStackedTrend()) {
+
+
+
+ } @else {
+
No data available.
+ }
+
+
+
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+
+
+
+
+
+
+
+
+ @if (selectedCard(); as card) {
+
+
+
+
+
+
{{ card.title }}
+
{{ drawerTimeRangeLabel() }} · {{ card.scopeLabel ?? 'All projects' }}
+
+
+ }
+
+ @if (selectedCard(); as card) {
+
+
+
+
{{ card.caption.emphasis || '—' }}
+
{{ card.caption.prefix }}{{ card.caption.emphasis }}{{ card.caption.suffix }}
+
+
+ @if (cardDetail(); as detail) {
+
+
+
+
+
+ | Definition |
+
+ {{ detail.definition.totalType === 'average' ? 'Average for this project' : 'Total count for this project' }}
+ |
+ Data source |
+
+
+
+
+ | {{ detail.definition.text }} |
+ {{ detail.definition.total }} |
+ {{ detail.definition.dataSource }} |
+
+
+ | Showing 1 of 1 row |
+
+
+
+
+
+
+ @if (detail.rows.length > 0) {
+
+
+
+
+ @for (col of detail.columns; track col) {
+ | {{ col }} |
+ }
+
+
+
+ @for (dataRow of detail.rows; track rowKey(dataRow)) {
+
+ @for (cell of dataRow.cells; track $index) {
+ |
+ @if (cell.person; as person) {
+
+
+ {{ person.initials }}
+
+ {{ person.name }}
+
+ } @else {
+ {{ cell.text }}
+ }
+ |
+ }
+
+ }
+
+
+
Showing {{ detail.rows.length }} of {{ detail.rows.length }} rows
+
+ } @else {
+
No data available for this metric.
+ }
+ }
+
+ }
+
diff --git a/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts
new file mode 100644
index 000000000..46c1e9392
--- /dev/null
+++ b/apps/lfx-one/src/app/modules/dashboards/org/org-project-detail/org-project-detail.component.ts
@@ -0,0 +1,679 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
+import { Component, computed, ElementRef, inject, PLATFORM_ID, signal, type Signal, viewChild } from '@angular/core';
+import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { AccountContextService } from '@services/account-context.service';
+import { OrgLensProjectDetailService } from '@services/org-lens-project-detail.service';
+import { BreadcrumbComponent } from '@components/breadcrumb/breadcrumb.component';
+import { ChartComponent } from '@components/chart/chart.component';
+import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
+import { InputTextComponent } from '@components/input-text/input-text.component';
+import { TableComponent } from '@components/table/table.component';
+import { TagComponent } from '@components/tag/tag.component';
+import { OrgProjectDetailTabBarComponent } from './org-project-detail-tab-bar.component';
+import {
+ BAND_CHIP_CLASS,
+ BAND_SIGNAL_FILL,
+ BAND_SIGNAL_FILL_LIGHT,
+ BAND_SIGNAL_RANK,
+ PD_BAND_TAG,
+ PD_DEFAULT_METRIC,
+ PD_DEFAULT_TAB,
+ PD_VALID_TABS,
+ PD_DEFAULT_TIME_RANGE,
+ PD_HEALTH_TAG,
+ lfxColors,
+ PD_METRIC_OPTIONS,
+ PD_STACKED_PALETTE,
+ PD_TIME_RANGE_MONTHS,
+ PD_TIME_RANGE_OPTIONS,
+ PD_VALID_METRICS,
+ PD_VALID_TIME_RANGES,
+} from '@lfx-one/shared/constants';
+import type {
+ InfluenceCardVm,
+ LeaderboardDimension,
+ OrgLensCardDetailRow,
+ OrgLensCardDetailSection,
+ OrgLensLeaderboardMetric,
+ OrgLensLeaderboardTimeRange,
+ OrgLensProjectBand,
+ OrgLensProjectDetailPageState,
+ OrgLensProjectDetailResponse,
+ OrgLensProjectDetailTab,
+ OrgLensProjectInfluenceCard,
+} from '@lfx-one/shared/interfaces';
+import { parseLocalDateString } from '@lfx-one/shared/utils';
+import type { MenuItem } from 'primeng/api';
+import { DrawerModule } from 'primeng/drawer';
+import { InputTextModule } from 'primeng/inputtext';
+import { SkeletonModule } from 'primeng/skeleton';
+import { TooltipModule } from 'primeng/tooltip';
+import type { ChartData, ChartOptions, ChartType } from 'chart.js';
+import { catchError, combineLatest, debounceTime, filter, map, type Observable, of, switchMap, tap } from 'rxjs';
+
+/** Band thresholds per Boysel et al. markup-mu (Leading / Contributing / Participating / Non-LF). */
+function bandForScore(score: number): OrgLensProjectBand {
+ if (score >= 80) return 'leading';
+ if (score >= 55) return 'contributing';
+ if (score >= 35) return 'participating';
+ return 'non-lf';
+}
+
+/**
+ * Org Lens · Project Detail sub-page (LFXV2-1885), routed at `/org/projects/:projectSlug`.
+ * Currently opened only from the Org Overview Foundations & Projects table; the standalone
+ * Projects table and Influence Summary cards are wired to this route in a later story. Owns
+ * the fetch keyed on the selected org + slug, the page-state machine, and the URL-persisted
+ * tab strip.
+ */
+@Component({
+ selector: 'lfx-org-project-detail',
+ imports: [
+ NgTemplateOutlet,
+ ReactiveFormsModule,
+ BreadcrumbComponent,
+ ChartComponent,
+ EmptyStateComponent,
+ InputTextComponent,
+ OrgProjectDetailTabBarComponent,
+ TableComponent,
+ TagComponent,
+ DrawerModule,
+ InputTextModule,
+ SkeletonModule,
+ TooltipModule,
+ ],
+ templateUrl: './org-project-detail.component.html',
+})
+export class OrgProjectDetailComponent {
+ private readonly techTrackRef = viewChild>('technicalTrack');
+ private readonly ecoTrackRef = viewChild>('ecosystemTrack');
+
+ protected readonly accountContext = inject(AccountContextService);
+ private readonly detailService = inject(OrgLensProjectDetailService);
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+ private readonly platformId = inject(PLATFORM_ID);
+
+ protected readonly retryTrigger = signal(0);
+ protected readonly fetchLoading = signal(true);
+ protected readonly fetchError = signal(false);
+ protected readonly detail = signal(null);
+ protected readonly techArrows = signal({ left: false, right: false });
+ protected readonly ecoArrows = signal({ left: false, right: false });
+ protected readonly selectedCard = signal(null);
+ protected readonly drawerOpen = signal(false);
+
+ protected readonly cardDetail = computed(() => {
+ const card = this.selectedCard();
+ if (!card) return null;
+ return this.detail()?.cardDetails?.[card.key] ?? null;
+ });
+
+ protected readonly tabs: { id: OrgLensProjectDetailTab; label: string; icon: string }[] = [
+ { id: 'pd-influence', label: 'Our Influence', icon: 'fa-light fa-chart-network' },
+ { id: 'pd-leaderboards', label: 'Leaderboards', icon: 'fa-light fa-ranking-star' },
+ ];
+
+ private readonly queryParamMap = toSignal(this.route.queryParamMap, { initialValue: this.route.snapshot.queryParamMap });
+
+ protected readonly activeTab: Signal = computed(() => this.initActiveTab());
+ protected readonly pageState: Signal = computed(() => this.initPageState());
+ protected readonly hasCompany = computed(() => !!this.accountContext.selectedAccount().uid);
+
+ // Hero presentation — derived from the loaded payload.
+ protected readonly hero = computed(() => this.detail()?.hero ?? null);
+ protected readonly breadcrumbItems = computed