Split monitored-system billing banner intents

This commit is contained in:
rcourtman 2026-04-06 09:23:25 +01:00
parent 80434b791d
commit e210230764
14 changed files with 223 additions and 22 deletions

View file

@ -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

View file

@ -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

View file

@ -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<CommercialSectionProps> = (props) => (
<div class="space-y-2 border-t border-border pt-4">
<section id={props.id} class="scroll-mt-24 space-y-2 border-t border-border pt-4">
<h3 class="text-sm font-semibold text-base-content">{props.title}</h3>
<p class="text-xs text-muted">{props.description}</p>
<div class="space-y-4">{props.children}</div>
</div>
</section>
);
export const CommercialStatGrid: Component<CommercialStatGridProps> = (props) => (

View file

@ -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 = () => {
>
<div class="space-y-6">
<CommercialSection
id={SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID}
title={SELF_HOSTED_PRO_BILLING_PRESENTATION.planSectionTitle}
description={SELF_HOSTED_PRO_BILLING_PRESENTATION.planSectionDescription}
>
@ -52,6 +57,7 @@ export const ProLicensePanel: Component = () => {
</CommercialSection>
<CommercialSection
id={SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID}
title={SELF_HOSTED_PRO_BILLING_PRESENTATION.usageSectionTitle}
description={getMonitoredSystemBriefSummary()}
>

View file

@ -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(() => <ProLicensePanel />);
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}');
});
});

View file

@ -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;

View file

@ -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 = () => {
<Show when={state.migrationGap()}>
<span class={`text-xs ${state.migrationTextClass()}`}>{state.migrationMessage()}</span>
</Show>
<a
<UpgradeLink
class="text-xs font-medium underline underline-offset-2 hover:opacity-90"
href={MONITORED_SYSTEM_LIMIT_BILLING_HREF}
destination={state.learnMoreDestination()}
>
{MONITORED_SYSTEM_LIMIT_LEARN_MORE_LABEL}
</a>
</UpgradeLink>
<Show when={state.migrationGap()}>
<a
<UpgradeLink
class="text-xs font-medium underline underline-offset-2 hover:opacity-90"
href={MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF}
destination={state.installCollectorsDestination()}
onClick={state.handleInstallCollectorsClick}
>
{MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL}
</a>
</UpgradeLink>
</Show>
<Show when={state.isUrgent()}>
<UpgradeLink

View file

@ -645,6 +645,7 @@ describe('shared primitive guardrails', () => {
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', () => {

View file

@ -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',
);
});
});

View file

@ -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 =

View file

@ -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,

View file

@ -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',
);
});

View file

@ -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(

View file

@ -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<string, string> = {
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');