diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 42c1ebb9c..5e3b7e4ba 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -222,6 +222,14 @@ runtime, and `frontend-modern/src/components/shared/activeUseTrialNudgeModel.ts` owns the eligibility policy, age threshold, and nudge copy/config. Future active-use trial work should extend those owners instead of pushing storage policy, timers, or commercial action flow back into the shared shell. +The shared trial banner now follows that same owner split. +`frontend-modern/src/components/shared/TrialBanner.tsx` stays the render +shell, `frontend-modern/src/components/shared/useTrialBannerState.ts` owns +entitlement load, snooze lifecycle, and upgrade-link runtime, and +`frontend-modern/src/components/shared/trialBannerModel.ts` owns day-count +normalization, tone policy, and display labels. Future trial-banner work +should extend those owners instead of pushing entitlement orchestration, +snooze state, or tone math back into the shared shell. The shared dialog now follows that same owner split. `frontend-modern/src/components/shared/Dialog.tsx` stays the render shell, `frontend-modern/src/components/shared/useDialogState.ts` owns focus trap, diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index db028c9d2..dd295d798 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -37,6 +37,8 @@ import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?r import searchTipsPopoverModelSource from '@/components/shared/searchTipsPopoverModel.ts?raw'; import tooltipSource from '@/components/shared/Tooltip.tsx?raw'; import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw'; +import trialBannerSource from '@/components/shared/TrialBanner.tsx?raw'; +import trialBannerModelSource from '@/components/shared/trialBannerModel.ts?raw'; import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw'; import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw'; import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw'; @@ -64,6 +66,7 @@ import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?r import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw'; import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw'; import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw'; +import trialBannerStateSource from '@/components/shared/useTrialBannerState.ts?raw'; import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw'; import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw'; import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw'; @@ -176,6 +179,30 @@ describe('shared primitive guardrails', () => { expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_TITLE'); }); + it('keeps trial banner on shell, runtime, and model owners', () => { + expect(trialBannerSource).toContain('useTrialBannerState'); + expect(trialBannerSource).toContain('TRIAL_BANNER_TITLE'); + expect(trialBannerSource).not.toContain('createSignal'); + expect(trialBannerSource).not.toContain('createMemo'); + expect(trialBannerSource).not.toContain('loadLicenseStatus'); + expect(trialBannerSource).not.toContain('licenseStatus'); + expect(trialBannerSource).not.toContain('getUpgradeActionUrlOrFallback'); + + expect(trialBannerStateSource).toContain('export function useTrialBannerState'); + expect(trialBannerStateSource).toContain('createSignal'); + expect(trialBannerStateSource).toContain('createMemo'); + expect(trialBannerStateSource).toContain('loadLicenseStatus'); + expect(trialBannerStateSource).toContain('licenseStatus'); + expect(trialBannerStateSource).toContain('getUpgradeActionUrlOrFallback'); + expect(trialBannerStateSource).toContain('snoozeUpsell'); + + expect(trialBannerModelSource).toContain('TRIAL_BANNER_SNOOZE_KEY'); + expect(trialBannerModelSource).toContain('normalizeTrialBannerDaysRemaining'); + expect(trialBannerModelSource).toContain('getTrialBannerToneClass'); + expect(trialBannerModelSource).toContain('getTrialBannerStatusLabel'); + expect(trialBannerModelSource).toContain('TRIAL_BANNER_UPGRADE_LABEL'); + }); + it('routes settings info callouts through CalloutCard', () => { expect(calloutCardSource).toContain( "type CalloutTone = 'danger' | 'info' | 'success' | 'warning'", diff --git a/frontend-modern/src/components/shared/TrialBanner.tsx b/frontend-modern/src/components/shared/TrialBanner.tsx index e86dd5edf..4ef35c39e 100644 --- a/frontend-modern/src/components/shared/TrialBanner.tsx +++ b/frontend-modern/src/components/shared/TrialBanner.tsx @@ -1,70 +1,43 @@ -import { Component, Show, createMemo, createSignal, onMount } from 'solid-js'; -import { getUpgradeActionUrlOrFallback, licenseStatus, loadLicenseStatus } from '@/stores/license'; -import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze'; - -const SNOOZE_KEY = 'pulse_trial_banner_snoozed'; +import { Component, Show } from 'solid-js'; +import { + getTrialBannerStatusLabel, + TRIAL_BANNER_SNOOZE_LABEL, + TRIAL_BANNER_TITLE, + TRIAL_BANNER_UPGRADE_LABEL, +} from './trialBannerModel'; +import { useTrialBannerState } from './useTrialBannerState'; export const TrialBanner: Component = () => { - const [snoozed, setSnoozed] = createSignal(isUpsellSnoozed(SNOOZE_KEY)); - - onMount(() => { - // Best-effort: if already loaded, this is a no-op. - void loadLicenseStatus(); - }); - - const isTrial = createMemo(() => licenseStatus()?.subscription_state === 'trial'); - const daysRemaining = createMemo(() => { - const raw = licenseStatus()?.trial_days_remaining; - if (typeof raw !== 'number' || !Number.isFinite(raw)) return null; - return Math.max(0, Math.floor(raw)); - }); - - const tone = createMemo(() => { - const days = daysRemaining(); - if (days !== null && days <= 1) { - return 'border-red-200 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-900 dark:text-red-100'; - } - if (days !== null && days <= 3) { - return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-900 dark:text-amber-100'; - } - return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-900 dark:text-blue-100'; - }); - - const handleSnooze = () => { - snoozeUpsell(SNOOZE_KEY); - setSnoozed(true); - }; + const state = useTrialBannerState(); return ( - +
- Pro Trial: - Active}> - {daysRemaining()} days remaining - + {TRIAL_BANNER_TITLE} + {getTrialBannerStatusLabel(state.daysRemaining())}
- +
- Upgrade + {TRIAL_BANNER_UPGRADE_LABEL}
diff --git a/frontend-modern/src/components/shared/__tests__/TrialBanner.test.tsx b/frontend-modern/src/components/shared/__tests__/TrialBanner.test.tsx new file mode 100644 index 000000000..c1bef894b --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/TrialBanner.test.tsx @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { TrialBanner } from '@/components/shared/TrialBanner'; +import trialBannerSource from '@/components/shared/TrialBanner.tsx?raw'; +import trialBannerModelSource from '@/components/shared/trialBannerModel.ts?raw'; +import trialBannerStateSource from '@/components/shared/useTrialBannerState.ts?raw'; +import { TRIAL_BANNER_SNOOZE_KEY } from '@/components/shared/trialBannerModel'; + +const { getUpgradeActionUrlOrFallbackMock, licenseStatusMock, loadLicenseStatusMock, isUpsellSnoozedMock, snoozeUpsellMock } = + vi.hoisted(() => ({ + getUpgradeActionUrlOrFallbackMock: vi.fn(), + licenseStatusMock: vi.fn(), + loadLicenseStatusMock: vi.fn(), + isUpsellSnoozedMock: vi.fn(), + snoozeUpsellMock: vi.fn(), + })); + +vi.mock('@/stores/license', () => ({ + getUpgradeActionUrlOrFallback: (...args: unknown[]) => getUpgradeActionUrlOrFallbackMock(...args), + licenseStatus: (...args: unknown[]) => licenseStatusMock(...args), + loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args), +})); + +vi.mock('@/utils/snooze', () => ({ + isUpsellSnoozed: (...args: unknown[]) => isUpsellSnoozedMock(...args), + snoozeUpsell: (...args: unknown[]) => snoozeUpsellMock(...args), +})); + +describe('TrialBanner', () => { + beforeEach(() => { + cleanup(); + getUpgradeActionUrlOrFallbackMock.mockReset(); + licenseStatusMock.mockReset(); + loadLicenseStatusMock.mockReset(); + isUpsellSnoozedMock.mockReset(); + snoozeUpsellMock.mockReset(); + getUpgradeActionUrlOrFallbackMock.mockReturnValue('/pricing?feature=trial_banner'); + loadLicenseStatusMock.mockResolvedValue(undefined); + isUpsellSnoozedMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it('keeps trial banner on shell, runtime, and model owners', () => { + expect(trialBannerSource).toContain('useTrialBannerState'); + expect(trialBannerSource).toContain('TRIAL_BANNER_TITLE'); + expect(trialBannerSource).not.toContain('createSignal'); + expect(trialBannerSource).not.toContain('createMemo'); + expect(trialBannerSource).not.toContain('loadLicenseStatus'); + expect(trialBannerSource).not.toContain('licenseStatus'); + expect(trialBannerSource).not.toContain('getUpgradeActionUrlOrFallback'); + + expect(trialBannerStateSource).toContain('export function useTrialBannerState'); + expect(trialBannerStateSource).toContain('createSignal'); + expect(trialBannerStateSource).toContain('createMemo'); + expect(trialBannerStateSource).toContain('loadLicenseStatus'); + expect(trialBannerStateSource).toContain('licenseStatus'); + expect(trialBannerStateSource).toContain('getUpgradeActionUrlOrFallback'); + expect(trialBannerStateSource).toContain('snoozeUpsell'); + + expect(trialBannerModelSource).toContain('TRIAL_BANNER_SNOOZE_KEY'); + expect(trialBannerModelSource).toContain('normalizeTrialBannerDaysRemaining'); + expect(trialBannerModelSource).toContain('getTrialBannerToneClass'); + expect(trialBannerModelSource).toContain('getTrialBannerStatusLabel'); + expect(trialBannerModelSource).toContain('TRIAL_BANNER_UPGRADE_LABEL'); + }); + + it('loads license status on mount and renders trial details when active', async () => { + licenseStatusMock.mockReturnValue({ + subscription_state: 'trial', + trial_days_remaining: 4.8, + }); + + render(() => ); + + await waitFor(() => { + expect(loadLicenseStatusMock).toHaveBeenCalled(); + }); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText('Pro Trial:')).toBeInTheDocument(); + expect(screen.getByText('4 days remaining')).toBeInTheDocument(); + expect(screen.getByText('Upgrade').closest('a')).toHaveAttribute( + 'href', + '/pricing?feature=trial_banner', + ); + }); + + it('shows active fallback when trial days are unavailable', () => { + licenseStatusMock.mockReturnValue({ + subscription_state: 'trial', + trial_days_remaining: undefined, + }); + + render(() => ); + + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('snoozes and hides the action row', async () => { + licenseStatusMock.mockReturnValue({ + subscription_state: 'trial', + trial_days_remaining: 2, + }); + + render(() => ); + + fireEvent.click(screen.getByRole('button', { name: 'Snooze 7d' })); + + expect(snoozeUpsellMock).toHaveBeenCalledWith(TRIAL_BANNER_SNOOZE_KEY); + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Snooze 7d' })).toBeNull(); + }); + }); +}); diff --git a/frontend-modern/src/components/shared/trialBannerModel.ts b/frontend-modern/src/components/shared/trialBannerModel.ts new file mode 100644 index 000000000..9c9593d73 --- /dev/null +++ b/frontend-modern/src/components/shared/trialBannerModel.ts @@ -0,0 +1,28 @@ +export const TRIAL_BANNER_SNOOZE_KEY = 'pulse_trial_banner_snoozed'; +export const TRIAL_BANNER_UPGRADE_REASON = 'trial_banner'; +export const TRIAL_BANNER_TITLE = 'Pro Trial:'; +export const TRIAL_BANNER_ACTIVE_LABEL = 'Active'; +export const TRIAL_BANNER_UPGRADE_LABEL = 'Upgrade'; +export const TRIAL_BANNER_SNOOZE_LABEL = 'Snooze 7d'; + +export function normalizeTrialBannerDaysRemaining(raw: unknown): number | null { + if (typeof raw !== 'number' || !Number.isFinite(raw)) return null; + return Math.max(0, Math.floor(raw)); +} + +export function getTrialBannerToneClass(daysRemaining: number | null): string { + if (daysRemaining !== null && daysRemaining <= 1) { + return 'border-red-200 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-900 dark:text-red-100'; + } + + if (daysRemaining !== null && daysRemaining <= 3) { + return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-900 dark:text-amber-100'; + } + + return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-900 dark:text-blue-100'; +} + +export function getTrialBannerStatusLabel(daysRemaining: number | null): string { + if (daysRemaining === null) return TRIAL_BANNER_ACTIVE_LABEL; + return `${daysRemaining} days remaining`; +} diff --git a/frontend-modern/src/components/shared/useTrialBannerState.ts b/frontend-modern/src/components/shared/useTrialBannerState.ts new file mode 100644 index 000000000..3f56ad287 --- /dev/null +++ b/frontend-modern/src/components/shared/useTrialBannerState.ts @@ -0,0 +1,40 @@ +import { createMemo, createSignal, onMount } from 'solid-js'; +import { getUpgradeActionUrlOrFallback, licenseStatus, loadLicenseStatus } from '@/stores/license'; +import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze'; +import { + getTrialBannerToneClass, + normalizeTrialBannerDaysRemaining, + TRIAL_BANNER_SNOOZE_KEY, + TRIAL_BANNER_UPGRADE_REASON, +} from './trialBannerModel'; + +export function useTrialBannerState() { + const [snoozed, setSnoozed] = createSignal(isUpsellSnoozed(TRIAL_BANNER_SNOOZE_KEY)); + + onMount(() => { + void loadLicenseStatus(); + }); + + const isTrial = createMemo(() => licenseStatus()?.subscription_state === 'trial'); + const daysRemaining = createMemo(() => + normalizeTrialBannerDaysRemaining(licenseStatus()?.trial_days_remaining), + ); + const toneClass = createMemo(() => getTrialBannerToneClass(daysRemaining())); + const upgradeHref = createMemo(() => + getUpgradeActionUrlOrFallback(TRIAL_BANNER_UPGRADE_REASON), + ); + + const handleSnooze = () => { + snoozeUpsell(TRIAL_BANNER_SNOOZE_KEY); + setSnoozed(true); + }; + + return { + daysRemaining, + handleSnooze, + isTrial, + showActions: () => !snoozed(), + toneClass, + upgradeHref, + }; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 5beca6d75..e3ed3ef99 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -42,6 +42,8 @@ import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?r import searchTipsPopoverModelSource from '@/components/shared/searchTipsPopoverModel.ts?raw'; import tooltipSource from '@/components/shared/Tooltip.tsx?raw'; import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw'; +import trialBannerSource from '@/components/shared/TrialBanner.tsx?raw'; +import trialBannerModelSource from '@/components/shared/trialBannerModel.ts?raw'; import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw'; import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw'; import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw'; @@ -63,6 +65,7 @@ import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?r import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw'; import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw'; import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw'; +import trialBannerStateSource from '@/components/shared/useTrialBannerState.ts?raw'; import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw'; import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw'; import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw'; @@ -2730,6 +2733,24 @@ describe('frontend resource type boundaries', () => { expect(activeUseTrialNudgeModelSource).toContain('isActiveUseTrialNudgeEligible'); expect(activeUseTrialNudgeModelSource).toContain('isActiveUseTrialNudgeOldEnough'); expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_TITLE'); + expect(trialBannerSource).toContain('useTrialBannerState'); + expect(trialBannerSource).toContain('TRIAL_BANNER_TITLE'); + expect(trialBannerSource).not.toContain('createSignal'); + expect(trialBannerSource).not.toContain('createMemo'); + expect(trialBannerSource).not.toContain('loadLicenseStatus'); + expect(trialBannerSource).not.toContain('licenseStatus'); + expect(trialBannerSource).not.toContain('getUpgradeActionUrlOrFallback'); + expect(trialBannerStateSource).toContain('createSignal'); + expect(trialBannerStateSource).toContain('createMemo'); + expect(trialBannerStateSource).toContain('loadLicenseStatus'); + expect(trialBannerStateSource).toContain('licenseStatus'); + expect(trialBannerStateSource).toContain('getUpgradeActionUrlOrFallback'); + expect(trialBannerStateSource).toContain('snoozeUpsell'); + expect(trialBannerModelSource).toContain('TRIAL_BANNER_SNOOZE_KEY'); + expect(trialBannerModelSource).toContain('normalizeTrialBannerDaysRemaining'); + expect(trialBannerModelSource).toContain('getTrialBannerToneClass'); + expect(trialBannerModelSource).toContain('getTrialBannerStatusLabel'); + expect(trialBannerModelSource).toContain('TRIAL_BANNER_UPGRADE_LABEL'); expect(whatsNewModalSource).toContain('useWhatsNewModalState'); expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS'); expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal');