diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index a656993038..86247cf184 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -8,6 +8,7 @@ import { Login } from './components/Login'; import { logger } from './utils/logger'; import { UpdateBanner } from './components/UpdateBanner'; import { DemoBanner } from './components/DemoBanner'; +import { CommercialMigrationBanner } from './components/CommercialMigrationBanner'; import { GitHubStarBanner } from './components/GitHubStarBanner'; // Modals are only mounted when opened, so their code can stay out of the // entry bundle until first use (same pattern as AIChat below). @@ -454,6 +455,7 @@ function App() { + diff --git a/frontend-modern/src/components/CommercialMigrationBanner.tsx b/frontend-modern/src/components/CommercialMigrationBanner.tsx new file mode 100644 index 0000000000..789b67aee0 --- /dev/null +++ b/frontend-modern/src/components/CommercialMigrationBanner.tsx @@ -0,0 +1,122 @@ +import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js'; +import { useNavigate } from '@solidjs/router'; +import { + commercialPosture, + commercialPostureLoaded, + loadCommercialPosture, +} from '@/stores/licenseCommercial'; +import { sessionPresentationPolicyResolved } from '@/stores/sessionPresentationPolicy'; +import { getCommercialMigrationNotice } from '@/utils/licensePresentation'; +import { SELF_HOSTED_PRO_BILLING_PLAN_ROUTE } from '@/utils/pricingHandoff'; + +const DISMISSED_KEY_PREFIX = 'commercialMigrationBannerDismissed:'; +const PENDING_RECHECK_INTERVAL_MS = 60_000; + +/** + * Global banner for an unresolved paid v5 license migration. The Settings + * licence panel already explains the state in detail, but a paying customer + * who upgraded should not have to discover missing Pro features before + * learning their migration needs attention. + */ +export function CommercialMigrationBanner() { + const navigate = useNavigate(); + const [dismissedState, setDismissedState] = createSignal(null); + + const migration = createMemo(() => commercialPosture()?.commercial_migration); + const notice = createMemo(() => getCommercialMigrationNotice(migration())); + + // Dismissal is per session and per migration state, so a pending → failed + // transition resurfaces the banner. The sessionStorage check lives in the + // memo because posture arrives asynchronously after mount. + const dismissed = createMemo(() => { + const state = migration()?.state; + if (!state) return false; + return ( + dismissedState() === state || + sessionStorage.getItem(DISMISSED_KEY_PREFIX + state) === 'true' + ); + }); + + // The app shell only loads commercial posture when upgrade prompts are + // enabled, but a migration warning is service degradation, not an upgrade + // nag — load the banner's own data dependency regardless. + createEffect(() => { + if (sessionPresentationPolicyResolved() && !commercialPostureLoaded()) { + void loadCommercialPosture(); + } + }); + + onMount(() => { + // A pending migration is retried by the backend in the background, so + // re-check periodically and let the banner clear itself on success. + const timer = setInterval(() => { + if (commercialPosture()?.commercial_migration?.state === 'pending') { + void loadCommercialPosture(true); + } + }, PENDING_RECHECK_INTERVAL_MS); + onCleanup(() => clearInterval(timer)); + }); + + const handleDismiss = () => { + const state = migration()?.state; + if (!state) return; + setDismissedState(state); + sessionStorage.setItem(DISMISSED_KEY_PREFIX + state, 'true'); + }; + + const toneClasses = createMemo(() => + migration()?.state === 'failed' + ? 'bg-red-50 dark:bg-red-900 border-b border-red-200 dark:border-red-800 text-red-800 dark:text-red-100' + : 'bg-amber-50 dark:bg-amber-900 border-b border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-100', + ); + const buttonClasses = createMemo(() => + migration()?.state === 'failed' + ? 'border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-800' + : 'border-amber-300 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-800', + ); + + return ( + + {(activeNotice) => ( +
+
+
+ + + + + {activeNotice().title}. {activeNotice().body} + +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend-modern/src/components/__tests__/CommercialMigrationBanner.test.tsx b/frontend-modern/src/components/__tests__/CommercialMigrationBanner.test.tsx new file mode 100644 index 0000000000..ed1b5145ea --- /dev/null +++ b/frontend-modern/src/components/__tests__/CommercialMigrationBanner.test.tsx @@ -0,0 +1,215 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CommercialMigrationStatus } from '@/api/license'; + +/* ------------------------------------------------------------------ */ +/* Mocks */ +/* ------------------------------------------------------------------ */ + +const commercialPostureMock = vi.hoisted(() => vi.fn()); +const commercialPostureLoadedMock = vi.hoisted(() => vi.fn()); +const loadCommercialPostureMock = vi.hoisted(() => vi.fn()); +const sessionPresentationPolicyResolvedMock = vi.hoisted(() => vi.fn()); +const navigateMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/stores/licenseCommercial', () => ({ + commercialPosture: () => commercialPostureMock(), + commercialPostureLoaded: () => commercialPostureLoadedMock(), + loadCommercialPosture: (force?: boolean) => loadCommercialPostureMock(force), +})); + +vi.mock('@/stores/sessionPresentationPolicy', () => ({ + sessionPresentationPolicyResolved: () => sessionPresentationPolicyResolvedMock(), +})); + +vi.mock('@solidjs/router', () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock('@/utils/logger', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function setMigration(migration?: CommercialMigrationStatus) { + commercialPostureMock.mockReturnValue( + migration === undefined + ? { tier: 'free', commercial_migration: undefined } + : { tier: 'free', commercial_migration: migration }, + ); +} + +async function renderBanner() { + const { CommercialMigrationBanner } = await import('../CommercialMigrationBanner'); + render(() => ); +} + +describe('CommercialMigrationBanner', () => { + beforeEach(() => { + commercialPostureMock.mockReset(); + commercialPostureMock.mockReturnValue(null); + commercialPostureLoadedMock.mockReset(); + commercialPostureLoadedMock.mockReturnValue(true); + loadCommercialPostureMock.mockReset(); + sessionPresentationPolicyResolvedMock.mockReset(); + sessionPresentationPolicyResolvedMock.mockReturnValue(true); + navigateMock.mockReset(); + sessionStorage.clear(); + }); + + afterEach(cleanup); + + /* ---------- Visibility ---------- */ + + it('stays hidden when there is no migration state', async () => { + setMigration(undefined); + + await renderBanner(); + + expect(screen.queryByText(/v5 license migration/)).not.toBeInTheDocument(); + }); + + it('shows a pending migration with the panel notice copy', async () => { + setMigration({ + source: 'v5_license', + state: 'pending', + reason: 'exchange_unavailable', + recommended_action: 'retry_activation', + }); + + await renderBanner(); + + expect(screen.getByText(/v5 license migration pending/)).toBeInTheDocument(); + expect(screen.getByText(/automatic v6 exchange did not complete yet/)).toBeInTheDocument(); + }); + + it('shows a failed migration including the unreadable persisted license reason', async () => { + setMigration({ + source: 'v5_license', + state: 'failed', + reason: 'persisted_license_unreadable', + recommended_action: 'enter_supported_v5_key', + }); + + await renderBanner(); + + expect(screen.getByText(/v5 license migration needs attention/)).toBeInTheDocument(); + expect(screen.getByText(/could not be read on this system/)).toBeInTheDocument(); + }); + + /* ---------- Action ---------- */ + + it('navigates to the license settings panel from the action button', async () => { + setMigration({ + source: 'v5_license', + state: 'pending', + reason: 'exchange_unavailable', + recommended_action: 'retry_activation', + }); + + await renderBanner(); + + fireEvent.click(screen.getByText('Open license settings')); + + expect(navigateMock).toHaveBeenCalledWith('/settings/system/billing/plan'); + }); + + /* ---------- Dismiss ---------- */ + + it('dismisses for the session, but resurfaces when the state changes', async () => { + setMigration({ + source: 'v5_license', + state: 'pending', + reason: 'exchange_unavailable', + recommended_action: 'retry_activation', + }); + + await renderBanner(); + + fireEvent.click(screen.getByTitle('Dismiss for this session')); + + await waitFor(() => { + expect(screen.queryByText(/v5 license migration pending/)).not.toBeInTheDocument(); + }); + expect(sessionStorage.getItem('commercialMigrationBannerDismissed:pending')).toBe('true'); + + // A pending → failed transition must resurface the banner. + setMigration({ + source: 'v5_license', + state: 'failed', + reason: 'exchange_invalid', + recommended_action: 'enter_supported_v5_key', + }); + cleanup(); + await renderBanner(); + + expect(screen.getByText(/v5 license migration needs attention/)).toBeInTheDocument(); + }); + + it('stays hidden when the current state was already dismissed this session', async () => { + sessionStorage.setItem('commercialMigrationBannerDismissed:pending', 'true'); + setMigration({ + source: 'v5_license', + state: 'pending', + reason: 'exchange_unavailable', + recommended_action: 'retry_activation', + }); + + await renderBanner(); + + expect(screen.queryByText(/v5 license migration pending/)).not.toBeInTheDocument(); + }); + + /* ---------- Data dependency ---------- */ + + it('loads commercial posture itself when the app shell has not', async () => { + commercialPostureLoadedMock.mockReturnValue(false); + setMigration(undefined); + + await renderBanner(); + + expect(loadCommercialPostureMock).toHaveBeenCalledWith(undefined); + }); + + it('does not reload posture when the app shell already loaded it', async () => { + setMigration(undefined); + + await renderBanner(); + + expect(loadCommercialPostureMock).not.toHaveBeenCalled(); + }); + + it('defers the posture load until the presentation policy resolves', async () => { + commercialPostureLoadedMock.mockReturnValue(false); + sessionPresentationPolicyResolvedMock.mockReturnValue(false); + setMigration(undefined); + + await renderBanner(); + + expect(loadCommercialPostureMock).not.toHaveBeenCalled(); + }); + + /* ---------- Self-healing ---------- */ + + it('re-checks posture while pending so the banner clears after background migration', async () => { + vi.useFakeTimers(); + try { + setMigration({ + source: 'v5_license', + state: 'pending', + reason: 'exchange_unavailable', + recommended_action: 'retry_activation', + }); + + await renderBanner(); + + vi.advanceTimersByTime(61_000); + expect(loadCommercialPostureMock).toHaveBeenCalledWith(true); + } finally { + vi.useRealTimers(); + } + }); +});