diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index cbc830ba9..0f1a00010 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -117,6 +117,11 @@ agreement, and cloud-specific enforcement rules. `internal/cloudcp/portal/` may surface the facts an operator needs, but the shell should not spend vertical space on duplicate context-chip strips when the page header and section body already communicate that scope. +23. Keep self-hosted monitored-system warning CTA intents distinct on the owned + billing surface. `Learn more` links must land on the monitored-system usage + section, while `Upgrade to add more` links must land on the plan/upgrade + section, so the product does not present duplicate labels for one + unscoped billing destination. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 331dc92c7..55f859856 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -196,6 +196,11 @@ work extends shared components instead of creating new local variants. `frontend-modern/src/hooks/createNonSuspendingQuery.ts` rather than re-copying suspense-escape logic into each feature area or burying it inside one feature's private state model. +14. Keep shared commercial warning banners truthful about destination intent. + When a shared banner renders both explanatory and commercial CTAs, those + labels must resolve to distinct owned destinations or section anchors + instead of presenting two different labels that land on the same + unscoped billing screen. ## Forbidden Paths diff --git a/frontend-modern/src/components/Settings/CommercialBillingSections.tsx b/frontend-modern/src/components/Settings/CommercialBillingSections.tsx index 1035879b4..31bfb49ba 100644 --- a/frontend-modern/src/components/Settings/CommercialBillingSections.tsx +++ b/frontend-modern/src/components/Settings/CommercialBillingSections.tsx @@ -14,6 +14,7 @@ export type CommercialUsageMeterItem = { }; interface CommercialSectionProps { + id?: string; title: string; description: string; children: JSX.Element; @@ -45,11 +46,11 @@ const usageRatio = (current: number, limit?: number) => { }; export const CommercialSection: Component = (props) => ( -
+

{props.title}

{props.description}

{props.children}
-
+ ); export const CommercialStatGrid: Component = (props) => ( diff --git a/frontend-modern/src/components/Settings/ProLicensePanel.tsx b/frontend-modern/src/components/Settings/ProLicensePanel.tsx index 6292fee74..3b3a3921e 100644 --- a/frontend-modern/src/components/Settings/ProLicensePanel.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePanel.tsx @@ -8,6 +8,10 @@ import { SelfHostedCommercialActivationSection } from './SelfHostedCommercialAct import { useProLicensePanelState } from './useProLicensePanelState'; import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation'; import { getMonitoredSystemBriefSummary } from '@/utils/monitoredSystemPresentation'; +import { + SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID, + SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID, +} from '@/utils/pricingHandoff'; export const ProLicensePanel: Component = () => { const state = useProLicensePanelState(); @@ -32,6 +36,7 @@ export const ProLicensePanel: Component = () => { >
@@ -52,6 +57,7 @@ export const ProLicensePanel: Component = () => { diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 211c516d0..0bbf2286c 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -7,7 +7,11 @@ import proLicensePanelSource from '../ProLicensePanel.tsx?raw'; import proLicensePanelStateSource from '../useProLicensePanelState.ts?raw'; import proLicensePlanSectionSource from '../ProLicensePlanSection.tsx?raw'; import selfHostedCommercialActivationSectionSource from '../SelfHostedCommercialActivationSection.tsx?raw'; -import { getPublicPricingUrl } from '@/utils/pricingHandoff'; +import { + getPublicPricingUrl, + SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID, + SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID, +} from '@/utils/pricingHandoff'; let mockEntitlements: LicenseEntitlements | null = null; @@ -19,6 +23,7 @@ const notificationSuccessMock = vi.fn(); const notificationErrorMock = vi.fn(); const useLocationMock = vi.fn(() => ({ search: '' })); const navigateMock = vi.fn(); +const scrollIntoViewMock = vi.fn(); const getUpgradeActionDestinationMock = vi.hoisted(() => vi.fn()); const getUpgradeActionUrlOrFallbackMock = vi.hoisted(() => vi.fn()); @@ -70,6 +75,7 @@ describe('ProLicensePanel', () => { notificationErrorMock.mockReset(); useLocationMock.mockReset(); navigateMock.mockReset(); + scrollIntoViewMock.mockReset(); getUpgradeActionDestinationMock.mockReset(); getUpgradeActionUrlOrFallbackMock.mockReset(); loadLicenseStatusMock.mockResolvedValue(undefined); @@ -82,10 +88,19 @@ describe('ProLicensePanel', () => { })); getUpgradeActionUrlOrFallbackMock.mockImplementation((feature?: string) => getPublicPricingUrl(feature)); useLocationMock.mockReturnValue({ search: '' }); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoViewMock, + }); }); afterEach(() => { cleanup(); + vi.unstubAllGlobals(); }); it('shows start trial action only when trial_eligible is true', async () => { @@ -329,6 +344,23 @@ describe('ProLicensePanel', () => { }); }); + it('anchors the plan and usage sections and scrolls to the requested billing hash', async () => { + useLocationMock.mockReturnValue({ + search: '', + pathname: '/settings/system/billing', + hash: `#${SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID}`, + }); + + render(() => ); + + await waitFor(() => { + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); + + expect(document.getElementById(SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID)).toBeInTheDocument(); + expect(document.getElementById(SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID)).toBeInTheDocument(); + }); + it('shows a migration-pending notice and hides the trial CTA', async () => { mockEntitlements = { capabilities: [], @@ -473,6 +505,8 @@ describe('ProLicensePanel', () => { expect(proLicensePanelSource).not.toContain('createSignal('); expect(proLicensePanelSource).not.toContain('useLocation()'); expect(proLicensePanelStateSource).toContain('useLocation'); + expect(proLicensePanelStateSource).toContain('requestAnimationFrame(scrollToBillingSectionHash);'); + expect(proLicensePanelStateSource).toContain('SELF_HOSTED_PRO_BILLING_SECTION_IDS'); expect(proLicensePanelStateSource).toContain('loadLicenseStatus(true)'); expect(proLicensePanelStateSource).toContain('buildSelfHostedCommercialPlanModel'); expect(proLicensePanelStateSource).toContain('runStartProTrialAction({'); @@ -506,5 +540,7 @@ describe('ProLicensePanel', () => { expect(selfHostedCommercialActivationSectionSource).not.toContain( 'Legacy v5 license detected', ); + expect(proLicensePanelSource).toContain('id={SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID}'); + expect(proLicensePanelSource).toContain('id={SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID}'); }); }); diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 2d473ee02..b0d4d40fc 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -1,4 +1,4 @@ -import { createMemo, createSignal, onMount } from 'solid-js'; +import { createEffect, createMemo, createSignal, onMount } from 'solid-js'; import { useLocation, useNavigate } from '@solidjs/router'; import { notificationStore } from '@/stores/notifications'; import { @@ -18,9 +18,18 @@ import { getTrialActivationNotice, isDisplayableLicenseFeature, } from '@/utils/licensePresentation'; +import { + SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID, + SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID, +} from '@/utils/pricingHandoff'; import { buildSelfHostedCommercialPlanModel } from '@/utils/commercialBillingModel'; import { runStartProTrialAction } from '@/utils/trialStartAction'; +const SELF_HOSTED_PRO_BILLING_SECTION_IDS = new Set([ + SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID, + SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID, +]); + const formatDate = (value?: string | null) => { if (!value) return 'Not available'; const date = new Date(value); @@ -69,6 +78,30 @@ export function useProLicensePanelState() { void loadPanelData(); }); + const scrollToBillingSectionHash = () => { + const hash = location.hash?.trim(); + if (!hash?.startsWith('#')) { + return; + } + + const sectionId = hash.slice(1); + if (!SELF_HOSTED_PRO_BILLING_SECTION_IDS.has(sectionId)) { + return; + } + + const target = document.getElementById(sectionId); + if (!target) { + return; + } + + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + createEffect(() => { + location.hash; + requestAnimationFrame(scrollToBillingSectionHash); + }); + const showTrialStart = createMemo(() => { const current = entitlements(); if (!current) return false; diff --git a/frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx b/frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx index 18afd32dd..fc5e4c604 100644 --- a/frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx +++ b/frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx @@ -1,7 +1,5 @@ import { Component, Show } from 'solid-js'; import { - MONITORED_SYSTEM_LIMIT_BILLING_HREF, - MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF, MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL, MONITORED_SYSTEM_LIMIT_LEARN_MORE_LABEL, MONITORED_SYSTEM_LIMIT_UPGRADE_LABEL, @@ -27,20 +25,20 @@ export const MonitoredSystemLimitWarningBanner: Component = () => { {state.migrationMessage()} - {MONITORED_SYSTEM_LIMIT_LEARN_MORE_LABEL} - + - {MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL} - + { expect(monitoredSystemLimitWarningBannerSource).toContain( 'useMonitoredSystemLimitWarningBannerState', ); + expect(monitoredSystemLimitWarningBannerSource).toContain('UpgradeLink'); expect(monitoredSystemLimitWarningBannerSource).toContain( 'MONITORED_SYSTEM_LIMIT_LEARN_MORE_LABEL', ); @@ -662,6 +663,12 @@ describe('shared primitive guardrails', () => { expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadLicenseStatus'); expect(monitoredSystemLimitWarningBannerStateSource).toContain('trackUpgradeMetricEvent'); expect(monitoredSystemLimitWarningBannerStateSource).toContain('legacyConnections'); + expect(monitoredSystemLimitWarningBannerStateSource).toContain( + 'anchorSelfHostedBillingDestination', + ); + expect(monitoredSystemLimitWarningBannerStateSource).toContain( + 'SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID', + ); expect(monitoredSystemLimitWarningBannerStateSource).toContain('handleUpgradeClick'); expect(monitoredSystemLimitWarningBannerModelSource).toContain( @@ -676,6 +683,9 @@ describe('shared primitive guardrails', () => { expect(monitoredSystemLimitWarningBannerModelSource).toContain( 'getMonitoredSystemLimitInstallCollectorsLabel', ); + expect(monitoredSystemLimitWarningBannerModelSource).toContain( + 'SELF_HOSTED_PRO_BILLING_USAGE_HREF', + ); }); it('keeps shared tag badges in the shared primitive boundary', () => { diff --git a/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx b/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx index 26c053464..65b084911 100644 --- a/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx @@ -71,7 +71,7 @@ describe('MonitoredSystemLimitWarningBanner', () => { href: '/settings/system/billing', external: false, }); - mockGetUpgradeActionUrlOrFallback.mockReturnValue('/settings/system/billing'); + mockGetUpgradeActionUrlOrFallback.mockReturnValue('/settings/system/billing#pulse-pro-plan'); }); afterEach(() => { @@ -162,8 +162,15 @@ describe('MonitoredSystemLimitWarningBanner', () => { )); expect(screen.getByText('Monitored systems: 5/6')).toBeInTheDocument(); + expect(screen.getByText('Learn more')).toHaveAttribute( + 'href', + '/settings/system/billing#pulse-pro-usage', + ); expect(screen.getByText('Upgrade to add more')).toBeInTheDocument(); - expect(screen.getByText('Upgrade to add more')).toHaveAttribute('href', '/settings/system/billing'); + expect(screen.getByText('Upgrade to add more')).toHaveAttribute( + 'href', + '/settings/system/billing#pulse-pro-plan', + ); expect(screen.queryByText('Install v6 collectors')).not.toBeInTheDocument(); }); @@ -194,6 +201,13 @@ describe('MonitoredSystemLimitWarningBanner', () => { expect( screen.getByText('Includes 1 temporary onboarding slot \(14d remaining\)', { exact: false }), ).toBeInTheDocument(); - expect(screen.getByText('Upgrade to add more')).toHaveAttribute('href', '/settings/system/billing'); + expect(screen.getByText('Learn more')).toHaveAttribute( + 'href', + '/settings/system/billing#pulse-pro-usage', + ); + expect(screen.getByText('Upgrade to add more')).toHaveAttribute( + 'href', + '/settings/system/billing#pulse-pro-plan', + ); }); }); diff --git a/frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts b/frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts index 60e8e2303..71e7a0fd0 100644 --- a/frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts +++ b/frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts @@ -8,6 +8,7 @@ import { getMonitoredSystemLimitUpgradeLabel, type MonitoredSystemLegacyConnectionCounts, } from '@/utils/monitoredSystemPresentation'; +import { SELF_HOSTED_PRO_BILLING_USAGE_HREF } from '@/utils/pricingHandoff'; type LimitState = { current: number; @@ -16,7 +17,7 @@ type LimitState = { }; export const MONITORED_SYSTEM_LIMIT_KEY = 'max_monitored_systems'; -export const MONITORED_SYSTEM_LIMIT_BILLING_HREF = '/settings/system/billing'; +export const MONITORED_SYSTEM_LIMIT_LEARN_MORE_HREF = SELF_HOSTED_PRO_BILLING_USAGE_HREF; export const MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF = '/settings'; export const MONITORED_SYSTEM_LIMIT_LEARN_MORE_LABEL = getMonitoredSystemLimitLearnMoreLabel(); export const MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL = diff --git a/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts b/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts index 034e365b0..ab2f3e11a 100644 --- a/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts +++ b/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts @@ -7,6 +7,11 @@ import { legacyConnections, loadLicenseStatus, } from '@/stores/license'; +import { resolveUpgradeDestination } from '@/utils/upgradeNavigation'; +import { + anchorSelfHostedBillingDestination, + SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID, +} from '@/utils/pricingHandoff'; import { trackUpgradeClicked, trackUpgradeMetricEvent, @@ -19,7 +24,9 @@ import { getMonitoredSystemOverflowSummary, getMonitoredSystemSummary, isMonitoredSystemLimitUrgent, + MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF, MONITORED_SYSTEM_LIMIT_KEY, + MONITORED_SYSTEM_LIMIT_LEARN_MORE_HREF, shouldShowMonitoredSystemLimitBanner, } from './monitoredSystemLimitWarningBannerModel'; @@ -46,8 +53,17 @@ export function useMonitoredSystemLimitWarningBannerState() { const migrationTextClass = createMemo(() => getMonitoredSystemMigrationTextClass(isUrgent()), ); + const learnMoreDestination = createMemo(() => + resolveUpgradeDestination(MONITORED_SYSTEM_LIMIT_LEARN_MORE_HREF), + ); + const installCollectorsDestination = createMemo(() => + resolveUpgradeDestination(MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF), + ); const upgradeDestination = createMemo(() => - getUpgradeActionDestination(MONITORED_SYSTEM_LIMIT_KEY), + anchorSelfHostedBillingDestination( + getUpgradeActionDestination(MONITORED_SYSTEM_LIMIT_KEY), + SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID, + ), ); let wasUrgent = false; @@ -80,7 +96,9 @@ export function useMonitoredSystemLimitWarningBannerState() { return { handleInstallCollectorsClick, handleUpgradeClick, + installCollectorsDestination, isUrgent, + learnMoreDestination, migrationGap, migrationMessage, migrationTextClass, diff --git a/frontend-modern/src/stores/__tests__/license.test.ts b/frontend-modern/src/stores/__tests__/license.test.ts index 59c8ca69d..a0acfc89c 100644 --- a/frontend-modern/src/stores/__tests__/license.test.ts +++ b/frontend-modern/src/stores/__tests__/license.test.ts @@ -325,7 +325,7 @@ describe('license store', () => { }); await loadLicenseStatus(true); expect(getUpgradeActionUrlOrFallback('max_monitored_systems')).toBe( - '/settings/system/billing', + '/settings/system/billing#pulse-pro-plan', ); }); diff --git a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts index d92da12b8..5a60dc31a 100644 --- a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts +++ b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts @@ -1,14 +1,19 @@ import { describe, expect, it } from 'vitest'; import { + anchorSelfHostedBillingDestination, getPricingRouteDestination, getPublicPricingUrl, getUpgradeFallbackDestination, isExternalPricingDestination, + SELF_HOSTED_PRO_BILLING_PLAN_HREF, + SELF_HOSTED_PRO_BILLING_USAGE_HREF, } from '@/utils/pricingHandoff'; describe('pricingHandoff', () => { it('routes product-owned monitored-system pricing links to billing', () => { - expect(getUpgradeFallbackDestination('max_monitored_systems')).toBe('/settings/system/billing'); + expect(getUpgradeFallbackDestination('max_monitored_systems')).toBe( + SELF_HOSTED_PRO_BILLING_PLAN_HREF, + ); }); it('routes product-owned cloud pricing links to the in-product cloud page', () => { @@ -35,10 +40,43 @@ describe('pricingHandoff', () => { it('keeps internal route exceptions when handing off the legacy pricing route', () => { expect(getPricingRouteDestination('?feature=max_monitored_systems')).toBe( - '/settings/system/billing', + SELF_HOSTED_PRO_BILLING_PLAN_HREF, ); }); + it('anchors in-product billing destinations to the requested section when no hash exists', () => { + expect( + anchorSelfHostedBillingDestination( + { href: '/settings/system/billing', external: false }, + 'pulse-pro-usage', + ), + ).toEqual({ + href: SELF_HOSTED_PRO_BILLING_USAGE_HREF, + external: false, + }); + }); + + it('keeps anchored or external destinations unchanged when scoping billing sections', () => { + expect( + anchorSelfHostedBillingDestination( + { href: SELF_HOSTED_PRO_BILLING_PLAN_HREF, external: false }, + 'pulse-pro-usage', + ), + ).toEqual({ + href: SELF_HOSTED_PRO_BILLING_PLAN_HREF, + external: false, + }); + expect( + anchorSelfHostedBillingDestination( + { href: getPublicPricingUrl('relay'), external: true }, + 'pulse-pro-usage', + ), + ).toEqual({ + href: getPublicPricingUrl('relay'), + external: true, + }); + }); + it('detects external pricing destinations', () => { expect( isExternalPricingDestination( diff --git a/frontend-modern/src/utils/pricingHandoff.ts b/frontend-modern/src/utils/pricingHandoff.ts index 928624281..df2fc1f41 100644 --- a/frontend-modern/src/utils/pricingHandoff.ts +++ b/frontend-modern/src/utils/pricingHandoff.ts @@ -1,13 +1,24 @@ -import { isExternalUpgradeHref } from '@/utils/upgradeNavigation'; +import { + isExternalUpgradeHref, + type UpgradeDestination, +} from '@/utils/upgradeNavigation'; const DEFAULT_PUBLIC_PRICING_URL = 'https://pulserelay.pro/pricing?utm_source=pulse&utm_medium=app&utm_campaign=upgrade'; +export const SELF_HOSTED_PRO_BILLING_ROUTE = '/settings/system/billing'; +export const SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID = 'pulse-pro-plan'; +export const SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID = 'pulse-pro-usage'; +export const SELF_HOSTED_PRO_BILLING_PLAN_HREF = `${SELF_HOSTED_PRO_BILLING_ROUTE}#${SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID}`; +export const SELF_HOSTED_PRO_BILLING_USAGE_HREF = `${SELF_HOSTED_PRO_BILLING_ROUTE}#${SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID}`; + const IN_PRODUCT_PRICING_DESTINATIONS: Record = { - max_monitored_systems: '/settings/system/billing', + max_monitored_systems: SELF_HOSTED_PRO_BILLING_PLAN_HREF, cloud: '/cloud', }; +const INTERNAL_HREF_BASE = 'https://pulse.invalid'; + function normalizeFeatureKey(feature: string | null | undefined): string | undefined { const normalized = feature?.trim(); return normalized ? normalized : undefined; @@ -34,6 +45,31 @@ export function getUpgradeFallbackDestination(feature?: string | null): string { return getInProductPricingDestination(feature) || getPublicPricingUrl(feature); } +export function anchorSelfHostedBillingDestination( + destination: UpgradeDestination, + sectionId: string, +): UpgradeDestination { + if (destination.external) { + return destination; + } + + const normalizedSectionId = sectionId.trim(); + if (!normalizedSectionId) { + return destination; + } + + const url = new URL(destination.href, INTERNAL_HREF_BASE); + if (url.pathname !== SELF_HOSTED_PRO_BILLING_ROUTE || url.hash) { + return destination; + } + + url.hash = normalizedSectionId; + return { + ...destination, + href: `${url.pathname}${url.search}${url.hash}`, + }; +} + export function getPricingRouteDestination(search: string): string { const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); const feature = params.get('feature');