mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-10 03:51:54 +00:00
Split monitored-system billing banner intents
This commit is contained in:
parent
80434b791d
commit
e210230764
14 changed files with 223 additions and 22 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue