Centralize trial CTA action handling

This commit is contained in:
rcourtman
2026-03-25 10:31:57 +00:00
parent b0908531cf
commit 79434a090d
33 changed files with 303 additions and 309 deletions
@@ -191,6 +191,12 @@ The dedicated profile client now also routes list, schema, and validation
parsing through shared response helpers in `frontend-modern/src/api/agentProfiles.ts`,
so profile transport stays aligned with the governed API contract instead of
reintroducing local array or JSON parsing rules.
That same lifecycle-owned install/profile surface now also routes trial-start
CTA orchestration through `frontend-modern/src/utils/trialStartAction.ts`.
Agent profile paywalls, NodeModal upgrade prompts, and setup-completion trial
actions may choose their own success copy, but they must use the shared helper
for hosted trial redirects and canonical denial handling instead of open-coding
`startProTrial()` branches in each lifecycle surface.
The owned backend API surfaces must preserve the exact-release installer
fallback, canonical /api/auto-register behavior, and hosted org install-command
@@ -203,6 +203,12 @@ The same facet bundle contract now also returns grouped
`recentChangeSourceAdapters` counts by canonical source adapter, so the
shared drawer and summary chips can distinguish Docker, Proxmox, TrueNAS, and
ops-helper provenance without inventing frontend-local integration heuristics.
Client consumers of the node setup transport now also share the canonical
trial-start action helper in `frontend-modern/src/utils/trialStartAction.ts`
for the NodeModal Pro upgrade path. The NodesAPI client remains the source of
truth for setup/install requests, while hosted trial redirects and denial copy
must flow through the shared trial-start owner rather than a second client-side
status-code map inside node setup state.
Canonical timeline entries now also preserve correlation context in
`relatedResources`, so the history surface can explain which neighboring
resources moved with restart, anomaly, config, state transition, and
@@ -231,6 +231,13 @@ The top-level authenticated shell is part of that same customer-facing
boundary: cloud-paid trial prompts may appear in owned commercial surfaces, but
the app shell must not force a global, persistent Pro trial nudge that
overrides the primary runtime chrome for every signed-in user.
The shared trial-start runtime is part of that same cloud-paid boundary.
Commercial relay, onboarding, setup, Pro settings, and shared paywall
surfaces may customize success copy, but they must route hosted handoff,
success-notification, and canonical denial handling through
`frontend-modern/src/utils/trialStartAction.ts` instead of carrying local
`startProTrial()` redirect/error branches that drift from backend commercial
truth.
The hosted trial handoff page is part of that same boundary as well. It may
still use a secure hosted Stripe-backed session internally, but the customer
copy must present the flow as starting a trial for the originating Pulse
@@ -210,8 +210,12 @@ Shared trial CTA handling is now part of that same primitive boundary for
settings and shared paywalls. Shared/settings runtime owners must derive trial
eligibility from the canonical entitlements payload, including
`trial_eligible`, and route operator-facing failure copy through
`frontend-modern/src/utils/upgradePresentation.ts` instead of open-coding local
409/429 branches that drift from backend denial reasons.
`frontend-modern/src/utils/upgradePresentation.ts`. The trial-start runtime
handoff itself is now centralized in
`frontend-modern/src/utils/trialStartAction.ts`; settings/shared paywalls and
onboarding surfaces must use that owner for redirect, success-notification, and
canonical denial handling instead of open-coding local `startProTrial()`
branches or re-interpreting backend status codes.
Top-level route files are now also expected to stay thin when a feature owns
the real product surface. `frontend-modern/src/pages/Infrastructure.tsx` now
acts only as the route boundary, while
@@ -160,3 +160,8 @@ through the canonical upgrade presentation helper instead of collapsing every
trial-start conflict into a generic already-used message. Organization settings
paywalls should only map the explicit canonical trial helper outputs, not
re-interpret status codes locally.
The RBAC feature-gate state now also depends on the shared
`frontend-modern/src/utils/trialStartAction.ts` owner for hosted handoff and
success/error orchestration. Organization settings paywalls must not keep a
lane-local `startProTrial()` branch once that shared helper covers the same
runtime contract.
@@ -213,6 +213,12 @@ The same page and drawer now also share the canonical
`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
card for recent changes, so the timeline layout and relative-time wording
stay governed by one frontend feed instead of separate page-local loops.
Patrol trial-entry surfaces now also share the canonical
`frontend-modern/src/utils/trialStartAction.ts` owner for hosted handoff and
denial handling. `ApprovalSection.tsx` and
`usePatrolIntelligenceState.ts` may still choose Patrol-specific success copy,
but they must not reintroduce local `startProTrial()` status-code branches
that diverge from the commercial backend contract.
That same store now owns the Patrol dashboard load bundle as well, so the
page refresh path stays aligned on a single orchestrated AI bundle instead of
repeating the individual summary, findings, approval, and correlation fetches
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import relayOnboardingCardStateSource from '../useRelayOnboardingCardState.ts?raw';
// ── Hoisted mocks ──────────────────────────────────────────────────────
@@ -125,6 +126,11 @@ function setupWithoutRelayFeature() {
// ── Tests ───────────────────────────────────────────────────────────────
describe('RelayOnboardingCard', () => {
it('keeps relay onboarding trial flow on the shared trial action owner', () => {
expect(relayOnboardingCardStateSource).toContain('runStartProTrialAction({');
expect(relayOnboardingCardStateSource).not.toContain('startProTrial()');
});
beforeEach(() => {
resetAllMocks();
});
@@ -5,13 +5,12 @@ import {
hasFeature,
licenseLoaded,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { logger } from '@/utils/logger';
import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze';
import { showError, showSuccess } from '@/utils/toast';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { getTrialStartErrorMessage } from '@/utils/upgradePresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
const SNOOZE_KEY = 'pulse_relay_onboarding_snoozed';
const RELAY_SETTINGS_PATH = '/settings/system-relay';
@@ -109,21 +108,17 @@ export function useRelayOnboardingCardState() {
setTrialStarting(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
const outcome = await runStartProTrialAction({
branded: true,
successMessage: 'Trial started. Relay is now available.',
showSuccess,
showError,
});
if (outcome === 'activated') {
await loadLicenseStatus(true);
setStatusLoaded(false);
void loadRelayStatusOnce();
}
showSuccess('Trial started. Relay is now available.');
await loadLicenseStatus(true);
setStatusLoaded(false);
void loadRelayStatusOnce();
} catch (error) {
logger.warn('[RelayOnboardingCard] Failed to start trial', error);
showError(getTrialStartErrorMessage(error, { branded: true }));
} finally {
setTrialStarting(false);
}
@@ -35,6 +35,8 @@ describe('NodeModal guardrails', () => {
expect(nodeModalSetupGuideSectionSource).toContain("await state.copyProxmoxAgentInstallCommand(");
expect(nodeModalMonitoringSectionSource).toContain('title="Monitoring coverage"');
expect(nodeModalStatusFooterSource).toContain('Start your free 14-day trial');
expect(nodeModalStateSource).toContain('runStartProTrialAction({');
expect(nodeModalStateSource).not.toContain('startProTrial()');
});
it('keeps the manual PVE permission snippet aligned with the canonical setup script', () => {
@@ -454,6 +454,8 @@ describe('ProLicensePanel', () => {
expect(proLicensePanelStateSource).toContain('useLocation');
expect(proLicensePanelStateSource).toContain('loadLicenseStatus(true)');
expect(proLicensePanelStateSource).toContain('buildSelfHostedCommercialPlanModel');
expect(proLicensePanelStateSource).toContain('runStartProTrialAction({');
expect(proLicensePanelStateSource).not.toContain('startProTrial()');
expect(proLicensePlanSectionSource).toContain('getLicenseStatusLoadingState');
expect(proLicensePlanSectionSource).toContain('getNoActiveProLicenseState');
});
@@ -466,7 +466,8 @@ describe('monitored-system model guardrails', () => {
expect(relayOnboardingCardStateSource).toContain('loadLicenseStatus()');
expect(relayOnboardingCardStateSource).toContain('RelayAPI.getStatus()');
expect(relayOnboardingCardStateSource).toContain('trackPaywallViewed');
expect(relayOnboardingCardStateSource).toContain('startProTrial()');
expect(relayOnboardingCardStateSource).toContain('runStartProTrialAction({');
expect(relayOnboardingCardStateSource).not.toContain('startProTrial()');
expect(infrastructureInstallStateSource).toContain('STORAGE_KEYS.SETUP_HANDOFF');
expect(infrastructureInstallerSectionSource).toContain(
'Security configured. Save these first-run credentials now.',
@@ -1242,7 +1243,8 @@ describe('monitored-system model guardrails', () => {
expect(rolesEditorDialogSource).toContain('RBAC_PERMISSION_RESOURCES');
expect(rbacFeatureGateSectionSource).toContain('trackUpgradeClicked');
expect(rbacFeatureGateStateSource).toContain('trackPaywallViewed');
expect(rbacFeatureGateStateSource).toContain('startProTrial');
expect(rbacFeatureGateStateSource).toContain('runStartProTrialAction({');
expect(rbacFeatureGateStateSource).not.toContain('startProTrial()');
expect(userAssignmentsDialogSource).toContain('Effective Permissions Preview');
expect(userAssignmentsPanelStateSource).toContain('RBACAPI.getUsers');
expect(userAssignmentsPanelStateSource).toContain('RBACAPI.updateUserRoles');
@@ -529,6 +529,8 @@ describe('Settings architecture guardrails', () => {
expect(monitoredSystemDefinitionDisclosureSource).not.toContain('{props.summary}');
expect(proLicensePanelStateSource).toContain('buildSelfHostedCommercialPlanModel');
expect(proLicensePanelStateSource).toContain('loadLicenseStatus(true)');
expect(proLicensePanelStateSource).toContain('runStartProTrialAction({');
expect(proLicensePanelStateSource).not.toContain('startProTrial()');
expect(proLicensePlanSectionSource).toContain('CommercialStatGrid');
expect(proLicensePlanSectionSource).toContain('getLicenseStatusLoadingState');
expect(monitoredSystemPresentationSource).toContain('export function getMonitoredSystemLedgerPresentation');
@@ -568,6 +570,8 @@ describe('Settings architecture guardrails', () => {
expect(relaySettingsPanelStateSource).toContain("trackPaywallViewed('relay', 'settings_relay_panel')");
expect(relaySettingsPanelStateSource).toContain('setInterval(() => void loadStatus(), 5000)');
expect(relaySettingsPanelStateSource).toContain('QRCode.toDataURL(payload.deep_link');
expect(relaySettingsPanelStateSource).toContain('runStartProTrialAction({');
expect(relaySettingsPanelStateSource).not.toContain('startProTrial()');
expect(relayPairingSectionSource).toContain('getRelayDiagnosticClass');
expect(relayPairingSectionSource).toContain('Pair New Device');
});
@@ -757,7 +761,8 @@ describe('Settings architecture guardrails', () => {
expect(userAssignmentsPanelSource).not.toContain('const loadData = async');
expect(rbacFeatureGateSectionSource).toContain('trackUpgradeClicked');
expect(rbacFeatureGateStateSource).toContain('trackPaywallViewed');
expect(rbacFeatureGateStateSource).toContain('startProTrial');
expect(rbacFeatureGateStateSource).toContain('runStartProTrialAction({');
expect(rbacFeatureGateStateSource).not.toContain('startProTrial()');
expect(rolesEditorDialogSource).toContain('RBAC_PERMISSION_ACTIONS');
expect(rolesPanelStateSource).toContain('RBACAPI.getRoles');
expect(rolesPanelStateSource).toContain('RBACAPI.saveRole');
@@ -948,6 +953,8 @@ describe('Settings architecture guardrails', () => {
expect(nodeModalStateSource).toContain('const [quickSetupBootstrap, setQuickSetupBootstrap] =');
expect(nodeModalStateSource).toContain('const handleTestConnection = async () =>');
expect(nodeModalStateSource).toContain("const PROXMOX_SETUP_HOST_REQUIRED_MESSAGE = 'Proxmox setup host is required';");
expect(nodeModalStateSource).toContain('runStartProTrialAction({');
expect(nodeModalStateSource).not.toContain('startProTrial()');
});
it('keeps AI settings sub-surfaces behind extracted runtime owners', () => {
@@ -980,7 +987,8 @@ describe('Settings architecture guardrails', () => {
expect(aiSettingsStateSource).toContain('const handleSave = async (event?: Event) =>');
expect(aiSettingsStateSource).toContain('const handleEnabledToggle = async (newValue: boolean) =>');
expect(aiSettingsStateSource).toContain('AIAPI.getSettings()');
expect(aiSettingsStateSource).toContain('showWarning(getTrialStartErrorMessage(error));');
expect(aiSettingsStateSource).toContain('runStartProTrialAction({');
expect(aiSettingsStateSource).not.toContain('startProTrial()');
expect(aiSettingsStateSource).not.toContain('getTrialAlreadyUsedMessage()');
});
@@ -1034,10 +1042,10 @@ describe('Settings architecture guardrails', () => {
expect(reportingPanelSource).not.toContain('window.URL.createObjectURL');
expect(reportingPanelStateSource).toContain('export const useReportingPanelState =');
expect(reportingPanelStateSource).toContain('loadLicenseStatus');
expect(reportingPanelStateSource).toContain('startProTrial');
expect(reportingPanelStateSource).toContain('runStartProTrialAction({');
expect(reportingPanelStateSource).not.toContain('startProTrial()');
expect(reportingPanelStateSource).toContain('buildReportingRequest');
expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage');
expect(reportingPanelStateSource).toContain('showWarning(getTrialStartErrorMessage(error));');
expect(reportingPanelStateSource).not.toContain('getTrialAlreadyUsedMessage()');
expect(reportingPanelModelSource).toContain('export function getReportingRangeStart');
expect(reportingPanelModelSource).toContain('export function buildReportingRequest');
@@ -1063,7 +1071,8 @@ describe('Settings architecture guardrails', () => {
expect(auditLogStateSource).toContain('const fetchAuditEvents = async (');
expect(auditLogStateSource).toContain('const verifyAllEvents = async (');
expect(auditLogStateSource).toContain('trackPaywallViewed');
expect(auditLogStateSource).toContain('showWarning(getTrialStartErrorMessage(err));');
expect(auditLogStateSource).toContain('runStartProTrialAction({');
expect(auditLogStateSource).not.toContain('startProTrial()');
expect(auditLogStateSource).not.toContain('getTrialAlreadyUsedMessage()');
});
@@ -1078,7 +1087,8 @@ describe('Settings architecture guardrails', () => {
expect(auditWebhookStateSource).toContain('const fetchWebhooks = async () =>');
expect(auditWebhookStateSource).toContain('const saveWebhooks = async (urls: string[]) =>');
expect(auditWebhookStateSource).toContain('trackPaywallViewed');
expect(auditWebhookStateSource).toContain('showWarning(getTrialStartErrorMessage(error));');
expect(auditWebhookStateSource).toContain('runStartProTrialAction({');
expect(auditWebhookStateSource).not.toContain('startProTrial()');
expect(auditWebhookStateSource).not.toContain('getTrialAlreadyUsedMessage()');
});
@@ -1094,9 +1104,8 @@ describe('Settings architecture guardrails', () => {
expect(ssoProvidersStateSource).toContain('const loadProviders = async () =>');
expect(ssoProvidersStateSource).toContain('const handleSave = async (event?: Event) =>');
expect(ssoProvidersStateSource).toContain('const testConnection = async () =>');
expect(ssoProvidersStateSource).toContain(
'notificationStore.error(getTrialStartErrorMessage(err));',
);
expect(ssoProvidersStateSource).toContain('runStartProTrialAction({');
expect(ssoProvidersStateSource).not.toContain('startProTrial()');
expect(ssoProvidersStateSource).not.toContain('getTrialAlreadyUsedMessage()');
expect(ssoProvidersModelSource).toContain('export const createEmptyProviderForm =');
expect(ssoProvidersModelSource).toContain('export const mapProviderDetailsToForm =');
@@ -15,7 +15,6 @@ import {
getUpgradeActionUrlOrFallback,
hasFeature,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { notificationStore } from '@/stores/notifications';
import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai';
@@ -38,11 +37,8 @@ import {
} from '@/utils/aiSettingsPresentation';
import { logger } from '@/utils/logger';
import { showSuccess, showWarning } from '@/utils/toast';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getProTrialStartedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
export const useAISettingsState = () => {
const [settings, setSettings] = createSignal<AISettingsType | null>(null);
@@ -278,14 +274,10 @@ export const useAISettingsState = () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
showSuccess(getProTrialStartedMessage());
} catch (error) {
showWarning(getTrialStartErrorMessage(error));
await runStartProTrialAction({
showSuccess,
showError: showWarning,
});
} finally {
setStartingTrial(false);
}
@@ -17,22 +17,19 @@ import {
licenseLoaded,
licenseLoading,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import type { ConnectedInfrastructureItem } from '@/types/api';
import type { Resource } from '@/types/resource';
import { formatRelativeTime } from '@/utils/format';
import { logger } from '@/utils/logger';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
UPGRADE_TRIAL_LABEL,
UPGRADE_TRIAL_LINK_CLASS,
} from '@/utils/upgradePresentation';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import { KNOWN_SETTINGS } from './agentProfileSettings';
import {
getActionableAgentIdFromResource,
@@ -218,21 +215,10 @@ export const useAgentProfilesPanelState = () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined),
);
}
await runStartProTrialAction({
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -13,13 +13,9 @@ import {
hasFeature,
licenseLoaded,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
getProTrialStartedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
export interface AuditEvent {
id: string;
@@ -526,14 +522,10 @@ export const useAuditLogPanelState = () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
showSuccess(getProTrialStartedMessage());
} catch (err) {
showWarning(getTrialStartErrorMessage(err));
await runStartProTrialAction({
showSuccess,
showError: showWarning,
});
} finally {
setStartingTrial(false);
}
@@ -8,19 +8,15 @@ import {
hasFeature,
licenseLoaded,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getProTrialStartedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
import {
getAuditWebhookDuplicateUrlMessage,
getAuditWebhookInvalidUrlMessage,
getAuditWebhookSaveErrorMessage,
getAuditWebhookSaveSuccessMessage,
} from '@/utils/auditWebhookPresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
export const useAuditWebhookPanelState = (canManageOverride?: boolean) => {
const [webhookUrls, setWebhookUrls] = createSignal<string[]>([]);
@@ -38,14 +34,10 @@ export const useAuditWebhookPanelState = (canManageOverride?: boolean) => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
showSuccess(getProTrialStartedMessage());
} catch (error) {
showWarning(getTrialStartErrorMessage(error));
await runStartProTrialAction({
showSuccess,
showError: showWarning,
});
} finally {
setStartingTrial(false);
}
@@ -4,7 +4,7 @@ import type { NodeConfig } from '@/types/nodes';
import { notificationStore } from '@/stores/notifications';
import { NodesAPI } from '@/api/nodes';
import type { ProxmoxSetupCommandResponse } from '@/api/nodes';
import { licenseStatus, startProTrial } from '@/stores/license';
import { licenseStatus } from '@/stores/license';
import { copyToClipboard } from '@/utils/clipboard';
import { logger } from '@/utils/logger';
import {
@@ -13,12 +13,7 @@ import {
getNodeModalTestResultPresentation,
type NodeModalFormData,
} from '@/utils/nodeModalPresentation';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
getTrialTryAgainLaterMessage,
} from '@/utils/upgradePresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import { deriveNameFromHost, type NodeModalProps } from './nodeModalModel';
@@ -71,27 +66,11 @@ export const useNodeModalState = (props: NodeModalProps) => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else if (statusCode === 429) {
notificationStore.error(getTrialTryAgainLaterMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined, {
branded: true,
}),
);
}
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -6,13 +6,8 @@ import {
licenseLoadError,
licenseStatus,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { LicenseAPI } from '@/api/license';
import {
getProTrialStartedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
import {
formatLicensePlanVersion,
getCommercialMigrationNotice,
@@ -23,6 +18,7 @@ import {
getTrialActivationNotice,
} from '@/utils/licensePresentation';
import { buildSelfHostedCommercialPlanModel } from '@/utils/commercialBillingModel';
import { runStartProTrialAction } from '@/utils/trialStartAction';
const formatDate = (value?: string | null) => {
if (!value) return 'Not available';
@@ -76,16 +72,11 @@ export function useProLicensePanelState() {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (error) {
notificationStore.error(getTrialStartErrorMessage(error, { branded: true }));
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -4,15 +4,11 @@ import {
hasFeature,
licenseLoaded,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { notificationStore } from '@/stores/notifications';
import { getRBACFeatureGateCopy, type RBACFeatureGateCopy } from '@/utils/rbacPresentation';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getProTrialStartedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
export type RBACFeatureGateKind = 'roles' | 'user-assignments';
export type RBACFeatureGateLocation =
@@ -54,14 +50,10 @@ export function useRBACFeatureGateState(options: UseRBACFeatureGateStateOptions)
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
notificationStore.error(getTrialStartErrorMessage(err));
await runStartProTrialAction({
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -5,7 +5,6 @@ import {
hasFeature,
licenseLoaded,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { showError, showSuccess } from '@/utils/toast';
@@ -14,6 +13,7 @@ import { OnboardingAPI, type OnboardingQRResponse } from '@/api/onboarding';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import { logger } from '@/utils/logger';
import { getRelayConnectionPresentation } from '@/utils/relayPresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import QRCode from 'qrcode';
export interface RelaySettingsPanelProps {
@@ -153,19 +153,11 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
showSuccess('Remote access trial started');
} catch (error) {
const statusCode = (error as { status?: number } | null)?.status;
if (statusCode === 409) {
showError('Trial already used');
} else {
showError(error instanceof Error ? error.message : 'Failed to start trial');
}
await runStartProTrialAction({
successMessage: 'Remote access trial started',
showSuccess,
showError,
});
} finally {
setStartingTrial(false);
}
@@ -8,18 +8,14 @@ import {
hasFeature,
licenseLoaded,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getProTrialStartedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
import {
getReportingGenerateErrorMessage,
getReportingGenerateSelectionRequiredMessage,
getReportingGenerateSuccessMessage,
} from '@/utils/reportingPresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import {
buildReportingRequest,
getReportingRangeStart,
@@ -57,14 +53,10 @@ export const useReportingPanelState = () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
showSuccess(getProTrialStartedMessage());
} catch (error) {
showWarning(getTrialStartErrorMessage(error));
await runStartProTrialAction({
showSuccess,
showError: showWarning,
});
} finally {
setStartingTrial(false);
}
@@ -7,7 +7,6 @@ import {
hasFeature,
loadLicenseStatus,
licenseLoaded,
startProTrial,
entitlements,
} from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
@@ -28,10 +27,7 @@ import {
getSSOProvidersLoadErrorMessage,
getSSOTestResultPresentation,
} from '@/utils/ssoProviderPresentation';
import {
getProTrialStartedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import type {
MetadataPreview,
ProviderForm,
@@ -81,14 +77,10 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
}
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
notificationStore.error(getTrialStartErrorMessage(err));
await runStartProTrialAction({
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -24,8 +24,6 @@ import {
import {
loadLicenseStatus,
entitlements,
getUpgradeActionUrlOrFallback,
startProTrial,
} from '@/stores/license';
import {
RELAY_ONBOARDING_SETUP_LABEL,
@@ -33,6 +31,7 @@ import {
RELAY_ONBOARDING_TRIAL_HINT,
RELAY_ONBOARDING_TRIAL_STARTING_LABEL,
} from '@/utils/relayPresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import type { WizardState } from '../SetupWizard';
interface CompleteStepProps {
@@ -299,23 +298,15 @@ Keep these credentials secure!
setTrialStarting(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
}
showSuccess('14-day Pro trial started! Set up Relay to monitor from your phone.');
setTrialStarted(true);
await loadLicenseStatus(true);
} catch (err) {
logger.warn('[SetupCompletionPanel] Failed to start trial; falling back to upgrade URL', err);
showError('Unable to start trial. Redirecting to upgrade options...');
const upgradeUrl = getUpgradeActionUrlOrFallback('relay');
if (typeof window !== 'undefined') {
window.location.href = upgradeUrl;
const outcome = await runStartProTrialAction({
branded: true,
successMessage: '14-day Pro trial started! Set up Relay to monitor from your phone.',
showSuccess,
showError,
});
if (outcome === 'activated') {
setTrialStarted(true);
await loadLicenseStatus(true);
}
} finally {
setTrialStarting(false);
@@ -12,6 +12,8 @@ describe('SetupCompletionPanel guardrails', () => {
expect(setupCompletionPanelSource).toContain('Use the Infrastructure Install workspace to:');
expect(setupCompletionPanelSource).toContain('generate Unified Agent tokens');
expect(setupCompletionPanelSource).toContain('configure TLS and custom CA options');
expect(setupCompletionPanelSource).toContain('runStartProTrialAction({');
expect(setupCompletionPanelSource).not.toContain('getUpgradeActionUrlOrFallback');
});
it('describes setup completion through the unified resource model instead of legacy install-command copy', () => {
@@ -9,16 +9,11 @@ import { Component, Show, createSignal, createResource, createMemo } from 'solid
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import { notificationStore } from '@/stores/notifications';
import { aiChatStore } from '@/stores/aiChat';
import { hasFeature, licenseStatus, startProTrial } from '@/stores/license';
import { hasFeature, licenseStatus } from '@/stores/license';
import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai';
import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation';
import { RemediationStatus } from './RemediationStatus';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
getTrialTryAgainLaterMessage,
} from '@/utils/upgradePresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
interface ApprovalSectionProps {
findingId: string;
@@ -59,27 +54,11 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else if (statusCode === 429) {
notificationStore.error(getTrialTryAgainLaterMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined, {
branded: true,
}),
);
}
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -89,7 +89,8 @@ describe('ActiveUseTrialNudge', () => {
expect(activeUseTrialNudgeStateSource).toContain('createMemo');
expect(activeUseTrialNudgeStateSource).toContain('window.localStorage');
expect(activeUseTrialNudgeStateSource).toContain('setInterval');
expect(activeUseTrialNudgeStateSource).toContain('startProTrial');
expect(activeUseTrialNudgeStateSource).toContain('runStartProTrialAction');
expect(activeUseTrialNudgeStateSource).not.toContain('startProTrial()');
expect(activeUseTrialNudgeStateSource).toContain('snoozeUpsell');
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY');
@@ -69,9 +69,8 @@ describe('HistoryChart', () => {
expect(historyChartStateSource).toContain('export function useHistoryChartState');
expect(historyChartStateSource).toContain('HISTORY_CHART_RANGES');
expect(historyChartStateSource).toContain('return ent.trial_eligible !== false;');
expect(historyChartStateSource).toContain(
'notificationStore.error(getTrialStartErrorMessage(err, { branded: true }));',
);
expect(historyChartStateSource).toContain('runStartProTrialAction({');
expect(historyChartStateSource).not.toContain('startProTrial()');
expect(historyChartStateSource).not.toContain('getTrialAlreadyUsedMessage()');
expect(historyChartStateSource).not.toContain('getTrialTryAgainLaterMessage()');
@@ -1,13 +1,8 @@
import { createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { licenseStatus, startProTrial } from '@/stores/license';
import { licenseStatus } from '@/stores/license';
import { notificationStore } from '@/stores/notifications';
import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
getTrialTryAgainLaterMessage,
} from '@/utils/upgradePresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import {
ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY,
ACTIVE_USE_TRIAL_NUDGE_REFRESH_MS,
@@ -66,27 +61,11 @@ export function useActiveUseTrialNudgeState() {
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (error) {
const statusCode = (error as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else if (statusCode === 429) {
notificationStore.error(getTrialTryAgainLaterMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(error instanceof Error ? error.message : undefined, {
branded: true,
}),
);
}
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -12,16 +12,12 @@ import {
licenseStatus,
loadLicenseStatus,
maxHistoryDays,
startProTrial,
} from '@/stores/license';
import { calculateOptimalPoints } from '@/utils/downsample';
import { setupCanvasDPR } from '@/utils/canvasRenderQueue';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { notificationStore } from '@/stores/notifications';
import {
getProTrialStartedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import {
HISTORY_CHART_RANGES,
createHistoryChartGeometry,
@@ -66,16 +62,11 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
notificationStore.error(getTrialStartErrorMessage(err, { branded: true }));
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -27,17 +27,11 @@ import {
hasFeature,
licenseStatus,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { getCanonicalScopeResourceIds } from '@/utils/patrolFormat';
import { buildPatrolInvestigationContextSummary } from './patrolInvestigationContextModel';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
getTrialTryAgainLaterMessage,
} from '@/utils/upgradePresentation';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { runStartProTrialAction } from '@/utils/trialStartAction';
interface ModelInfo {
id: string;
@@ -171,36 +165,26 @@ export function usePatrolIntelligenceState() {
const autoFixLocked = createMemo(() => !hasFeature('ai_autofix'));
const canStartTrial = createMemo(() => {
const state = licenseStatus()?.subscription_state;
if (!state) return false;
return state !== 'active' && state !== 'trial';
const entitlements = licenseStatus();
if (!entitlements) return false;
if (
entitlements.subscription_state === 'active' ||
entitlements.subscription_state === 'trial'
) {
return false;
}
return entitlements.trial_eligible !== false;
});
async function handleStartTrial() {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else if (statusCode === 429) {
notificationStore.error(getTrialTryAgainLaterMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined, {
branded: true,
}),
);
}
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
@@ -1564,7 +1564,8 @@ describe('frontend resource type boundaries', () => {
expect(relayOnboardingCardSource).not.toContain('RelayAPI.getStatus()');
expect(relayOnboardingCardStateSource).toContain('RelayAPI.getStatus()');
expect(relayOnboardingCardStateSource).toContain('loadLicenseStatus()');
expect(relayOnboardingCardStateSource).toContain('startProTrial()');
expect(relayOnboardingCardStateSource).toContain('runStartProTrialAction({');
expect(relayOnboardingCardStateSource).not.toContain('startProTrial()');
expect(organizationBillingPanelSource).not.toContain('normalizeOrgScope(getOrgID())');
expect(organizationBillingPanelSource).not.toContain('createSignal(');
expect(billingAdminPanelSource).not.toContain('createSignal(');
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const startProTrialMock = vi.hoisted(() => vi.fn());
vi.mock('@/stores/license', () => ({
startProTrial: (...args: unknown[]) => startProTrialMock(...args),
}));
import { runStartProTrialAction } from '@/utils/trialStartAction';
describe('runStartProTrialAction', () => {
const showSuccess = vi.fn();
const showError = vi.fn();
const navigate = vi.fn();
beforeEach(() => {
startProTrialMock.mockReset();
showSuccess.mockReset();
showError.mockReset();
navigate.mockReset();
});
it('reports activation success through the shared success message path', async () => {
startProTrialMock.mockResolvedValue({ outcome: 'activated' });
await expect(
runStartProTrialAction({
showSuccess,
showError,
navigate,
}),
).resolves.toBe('activated');
expect(showSuccess).toHaveBeenCalledWith('Pro trial started');
expect(showError).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
it('navigates through the hosted handoff when the backend requires signup', async () => {
startProTrialMock.mockResolvedValue({
outcome: 'redirect',
actionUrl: 'https://cloud.pulserelay.pro/start-pro-trial',
});
await expect(
runStartProTrialAction({
showSuccess,
showError,
navigate,
}),
).resolves.toBe('redirect');
expect(navigate).toHaveBeenCalledWith('https://cloud.pulserelay.pro/start-pro-trial');
expect(showSuccess).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
});
it('preserves canonical denial messages instead of collapsing conflicts locally', async () => {
startProTrialMock.mockRejectedValue({
status: 409,
code: 'trial_not_available',
message: 'Trial cannot be started while a paid v5 license migration is pending',
});
await expect(
runStartProTrialAction({
showSuccess,
showError,
navigate,
}),
).resolves.toBe('error');
expect(showError).toHaveBeenCalledWith(
'Trial cannot be started while a paid v5 license migration is pending',
);
expect(showSuccess).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,38 @@
import { startProTrial } from '@/stores/license';
import { getProTrialStartedMessage, getTrialStartErrorMessage } from '@/utils/upgradePresentation';
export type StartProTrialActionOutcome = 'activated' | 'redirect' | 'error';
export interface RunStartProTrialActionOptions {
branded?: boolean;
successMessage?: string;
showSuccess: (message: string) => void;
showError: (message: string) => void;
navigate?: (actionUrl: string) => void;
}
function defaultNavigate(actionUrl: string) {
if (typeof window === 'undefined') return;
window.location.href = actionUrl;
}
export async function runStartProTrialAction(
options: RunStartProTrialActionOptions,
): Promise<StartProTrialActionOutcome> {
const { branded = false, navigate = defaultNavigate, showError, showSuccess, successMessage } =
options;
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
navigate(result.actionUrl);
return 'redirect';
}
showSuccess(successMessage ?? getProTrialStartedMessage());
return 'activated';
} catch (error) {
showError(getTrialStartErrorMessage(error, { branded }));
return 'error';
}
}