diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 8a833bbad..633ff7a3a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 19b3659c8..46b793cdc 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 2afcaf281..db2dcba2e 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index f4fdf3811..7d87eeb63 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index 5bf6f9760..86af31e23 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 4ee083ea6..9bb2a8f23 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx b/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx index 923bdcfa7..07d9bccf0 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx +++ b/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx @@ -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(); }); diff --git a/frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts b/frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts index f8ffa6815..6ff90c29d 100644 --- a/frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts +++ b/frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts @@ -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); } diff --git a/frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts index 00da99153..d7b0b2f00 100644 --- a/frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/NodeModal.guardrails.test.ts @@ -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', () => { diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 2646482f6..5610a309e 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -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'); }); diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index ef165b149..f7befc1c4 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -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'); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index a983c2aae..388bca14e 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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 ='); diff --git a/frontend-modern/src/components/Settings/useAISettingsState.ts b/frontend-modern/src/components/Settings/useAISettingsState.ts index 30c198016..5bd63bf5f 100644 --- a/frontend-modern/src/components/Settings/useAISettingsState.ts +++ b/frontend-modern/src/components/Settings/useAISettingsState.ts @@ -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(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); } diff --git a/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts b/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts index 7190abbc6..8639e15a6 100644 --- a/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts +++ b/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts @@ -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); } diff --git a/frontend-modern/src/components/Settings/useAuditLogPanelState.ts b/frontend-modern/src/components/Settings/useAuditLogPanelState.ts index c5c977cc3..ccf859998 100644 --- a/frontend-modern/src/components/Settings/useAuditLogPanelState.ts +++ b/frontend-modern/src/components/Settings/useAuditLogPanelState.ts @@ -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); } diff --git a/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts b/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts index 8b4a15e86..36ac3d4f5 100644 --- a/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts +++ b/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts @@ -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([]); @@ -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); } diff --git a/frontend-modern/src/components/Settings/useNodeModalState.ts b/frontend-modern/src/components/Settings/useNodeModalState.ts index c66938bcb..82f96dd06 100644 --- a/frontend-modern/src/components/Settings/useNodeModalState.ts +++ b/frontend-modern/src/components/Settings/useNodeModalState.ts @@ -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); } diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 6e89287a3..ee972eac5 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -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); } diff --git a/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts b/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts index 045d4e278..6ce32c1cf 100644 --- a/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts +++ b/frontend-modern/src/components/Settings/useRBACFeatureGateState.ts @@ -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); } diff --git a/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts b/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts index 8d713e1a6..155e31220 100644 --- a/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts +++ b/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts @@ -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); } diff --git a/frontend-modern/src/components/Settings/useReportingPanelState.ts b/frontend-modern/src/components/Settings/useReportingPanelState.ts index c47a80f1e..d7b4647c0 100644 --- a/frontend-modern/src/components/Settings/useReportingPanelState.ts +++ b/frontend-modern/src/components/Settings/useReportingPanelState.ts @@ -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); } diff --git a/frontend-modern/src/components/Settings/useSSOProvidersState.ts b/frontend-modern/src/components/Settings/useSSOProvidersState.ts index 901ffdacd..f37a8c20b 100644 --- a/frontend-modern/src/components/Settings/useSSOProvidersState.ts +++ b/frontend-modern/src/components/Settings/useSSOProvidersState.ts @@ -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); } diff --git a/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx b/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx index 231a3d834..cc4ab82a9 100644 --- a/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx +++ b/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx @@ -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); diff --git a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts index 60463f66c..0d8edfd19 100644 --- a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts +++ b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts @@ -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', () => { diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index b32b68717..25d536e07 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -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 = (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); } diff --git a/frontend-modern/src/components/shared/__tests__/ActiveUseTrialNudge.test.tsx b/frontend-modern/src/components/shared/__tests__/ActiveUseTrialNudge.test.tsx index 7e39a16a7..536424e3e 100644 --- a/frontend-modern/src/components/shared/__tests__/ActiveUseTrialNudge.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/ActiveUseTrialNudge.test.tsx @@ -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'); diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index a6807c0ea..5e2c1e69a 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -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()'); diff --git a/frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts b/frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts index 3b77403b5..a3ab6f204 100644 --- a/frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts +++ b/frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts @@ -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); } diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index b34d2b047..06b79ad0a 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -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); } diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index a7e0b19d6..33fe0125b 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -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); } diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index df525a724..e35ad6cf3 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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('); diff --git a/frontend-modern/src/utils/__tests__/trialStartAction.test.ts b/frontend-modern/src/utils/__tests__/trialStartAction.test.ts new file mode 100644 index 000000000..a8dad05e1 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/trialStartAction.test.ts @@ -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(); + }); +}); diff --git a/frontend-modern/src/utils/trialStartAction.ts b/frontend-modern/src/utils/trialStartAction.ts new file mode 100644 index 000000000..1af7ce051 --- /dev/null +++ b/frontend-modern/src/utils/trialStartAction.ts @@ -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 { + 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'; + } +}