fix(commercial): land upgrade entry points on plan selection

Every in-product upgrade destination (gate CTAs, the business-estate card,
the public /pricing resolver for mapped features) routed to the bare billing
plan page, which renders plan comparison and selection only when the
intent=self_hosted_plan query parameter is present — so the surfaces asked
users to look at plans and then landed them on a page that showed none.
Route them all through the plan-selection href; checkout source attribution
composes with the intent param unchanged, and public /pricing arrivals stay
unsourced.

The Patrol page had the same dead end plus a context gap: plan-locked free
sessions saw only a lone "Plans & Billing" button with the mode selector
hidden, so the upsell never showed what upgrading unlocks. Render the four
Patrol modes with paid modes disabled and Pro-badged for plan-locked
sessions (the presentation already used for runtime-locked installs), keep
it fully suppressed when commercial surfaces are hidden, and relabel the
action "Unlock Patrol modes".
This commit is contained in:
rcourtman
2026-08-08 12:51:42 +01:00
parent e391d631d1
commit 7d95a707bc
10 changed files with 49 additions and 36 deletions
@@ -11,6 +11,7 @@ import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentat
import {
getSelfHostedBillingHref,
PURCHASE_HANDOFF_SOURCE_ESTATE_CARD,
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
} from '@/utils/pricingHandoff';
import { ActionIconButton, Button } from '@/components/shared/Button';
import BriefcaseIcon from 'lucide-solid/icons/briefcase';
@@ -107,7 +108,12 @@ export function BusinessEstateCard() {
const handleSeePlans = () => {
setDismissed(true);
setShowCard(false);
navigate(getSelfHostedBillingHref('plan', { source: PURCHASE_HANDOFF_SOURCE_ESTATE_CARD }));
navigate(
getSelfHostedBillingHref('plan', {
intent: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
source: PURCHASE_HANDOFF_SOURCE_ESTATE_CARD,
}),
);
};
createEffect(() => {
@@ -167,7 +167,7 @@ describe('BusinessEstateCard', () => {
expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true);
});
it('navigates to the plan route and dismisses permanently on the primary action', async () => {
it('navigates to plan selection and dismisses permanently on the primary action', async () => {
seedEligibleState();
await renderCard();
@@ -176,7 +176,7 @@ describe('BusinessEstateCard', () => {
fireEvent.click(screen.getByRole('button', { name: 'See business plans' }));
expect(mockNavigate).toHaveBeenCalledWith(
'/settings/pulse-intelligence/billing/plan?source=estate-card',
'/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan&source=estate-card',
);
expect(queryCard()).toBeNull();
expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true);
@@ -142,7 +142,10 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
}),
);
const shouldShowAutonomyOptions = createMemo(
() => canChooseAutonomyLevel() || autonomyAvailability().kind === 'runtime_locked',
() =>
canChooseAutonomyLevel() ||
autonomyAvailability().kind === 'runtime_locked' ||
(autonomyAvailability().kind === 'plan_locked' && !commercialSurfacesHidden()),
);
const showAutonomyUpgradeAction = createMemo(
() =>
@@ -99,7 +99,7 @@ describe('PatrolIntelligenceHeader', () => {
expect(headerSource).toContain('sm:min-h-0');
});
it('makes Patrol mode a simple four-level choice without rendering plan-locked paid modes', () => {
it('makes Patrol mode a simple four-level choice with plan-locked paid modes disabled and Pro-badged', () => {
expect(PATROL_AUTONOMY_POLICY_PRESENTATION).toEqual({
monitor: {
label: 'Watch only',
@@ -222,7 +222,7 @@ describe('PatrolIntelligenceHeader', () => {
locked: true,
title: 'Watch only',
body: 'This install watches infrastructure and shows issues.',
actionLabel: 'Plans & Billing',
actionLabel: 'Unlock Patrol modes',
destination: {
href: '/settings/pulse-intelligence/billing/plan',
external: false,
@@ -263,12 +263,12 @@ describe('patrolAutonomyAvailability', () => {
});
expect(result.kind).toBe('plan_locked');
expect(result.actionLabel).toBe('Plans & Billing');
expect(result.actionLabel).toBe('Unlock Patrol modes');
});
});
describe('plan_locked presentation', () => {
it('shows the Plans & Billing action when surfaces are visible', () => {
it('shows the unlock action when surfaces are visible', () => {
const result = getPatrolAutonomyAvailabilityPresentation({
autoFixLocked: true,
planUpgradeDestination: planDestination,
@@ -279,7 +279,7 @@ describe('patrolAutonomyAvailability', () => {
locked: true,
title: 'Watch only',
body: 'This install watches infrastructure and shows issues.',
actionLabel: 'Plans & Billing',
actionLabel: 'Unlock Patrol modes',
destination: planDestination,
});
});
@@ -22,7 +22,7 @@ describe('patrol commercial boundary', () => {
expect(patrolIntelligenceHeaderSource).toContain(
"autonomyAvailability().kind === 'plan_locked'",
);
expect(patrolAutonomyAvailabilitySource).toContain('Plans & Billing');
expect(patrolAutonomyAvailabilitySource).toContain('Unlock Patrol modes');
expect(patrolAutonomyAvailabilitySource).toContain('input.upgradePromptsHidden');
expect(patrolIntelligenceHeaderSource).toContain('getPatrolAutonomyAvailabilityPresentation');
expect(patrolIntelligenceHeaderSource).toContain('!presentationPolicyHidesUpgradePrompts()');
@@ -83,7 +83,7 @@ export function getPatrolAutonomyAvailabilityPresentation(
...(input.upgradePromptsHidden
? {}
: {
actionLabel: 'Plans & Billing',
actionLabel: 'Unlock Patrol modes',
destination: input.planUpgradeDestination,
}),
};
@@ -123,13 +123,17 @@ describe('pricingHandoff (branch coverage)', () => {
describe('getInProductPricingDestination', () => {
it('maps known in-product feature keys to their plan/selection hrefs', () => {
expect(getInProductPricingDestination('relay')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
expect(getInProductPricingDestination('mobile_app')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
expect(getInProductPricingDestination('relay')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
);
expect(getInProductPricingDestination('mobile_app')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
);
expect(getInProductPricingDestination('long_term_metrics')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
);
expect(getInProductPricingDestination('agent_profiles')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
);
expect(getInProductPricingDestination('self_hosted_plan')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
@@ -46,27 +46,27 @@ describe('pricingHandoff', () => {
expect(getInProductPricingDestination('cloud')).toBeUndefined();
});
it('routes paid self-hosted feature upgrades to the in-product billing plan page with gate attribution', () => {
it('routes paid self-hosted feature upgrades to in-product plan selection with gate attribution', () => {
expect(getUpgradeFallbackDestination('relay')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-relay`,
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-relay`,
);
expect(getUpgradeFallbackDestination('mobile_app')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-mobile-app`,
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-mobile-app`,
);
expect(getUpgradeFallbackDestination('push_notifications')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-push-notifications`,
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-push-notifications`,
);
expect(getUpgradeFallbackDestination('ai_alerts')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-ai-alerts`,
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-alerts`,
);
expect(getUpgradeFallbackDestination('ai_autofix')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-autofix`,
);
expect(getUpgradeFallbackDestination('rbac')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-rbac`,
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-rbac`,
);
expect(getUpgradeFallbackDestination('advanced_reporting')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-reporting`,
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-reporting`,
);
});
@@ -106,9 +106,7 @@ describe('pricingHandoff', () => {
};
for (const key of paidCatalogFeatureKeys) {
expect(getUpgradeFallbackDestination(key)).toBe(
key === 'ai_autofix'
? `${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-autofix`
: `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=${expectedGateSources[key]}`,
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=${expectedGateSources[key]}`,
);
}
});
@@ -143,7 +141,9 @@ describe('pricingHandoff', () => {
`${SELF_HOSTED_PURCHASE_START_PATH}?feature=relay`,
);
// The public /pricing resolver stays unsourced for mapped features.
expect(getPricingRouteDestination('?feature=rbac')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
expect(getPricingRouteDestination('?feature=rbac')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
);
});
it('keeps retired trial-expired out of upgrade fallbacks while preserving neutral legacy arrival', () => {
+10 -10
View File
@@ -102,17 +102,17 @@ const IN_PRODUCT_PRICING_DESTINATIONS: Record<string, string> = {
// Paid self-hosted feature keys: route to the owned billing plan page instead
// of the Pulse Account purchase-start handoff, which fails for local instances
// without PublicURL.
mobile_app: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
push_notifications: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
ai_alerts: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
mobile_app: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
push_notifications: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
ai_alerts: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
ai_autofix: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
relay: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
rbac: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
audit_logging: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
advanced_reporting: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
agent_profiles: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
external_probe: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
long_term_metrics: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
relay: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
rbac: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
audit_logging: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
advanced_reporting: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
agent_profiles: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
external_probe: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
long_term_metrics: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
};
const RETIRED_TRIAL_PRICING_FEATURES = new Set(['trial_expired']);