Split trial banner owners

This commit is contained in:
rcourtman
2026-03-23 08:50:18 +00:00
parent 76290a10fa
commit 3199455bc4
7 changed files with 258 additions and 45 deletions
@@ -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,
@@ -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'",
@@ -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 (
<Show when={isTrial()}>
<Show when={state.isTrial()}>
<div
class={`mb-2 rounded-md border px-3 py-2 text-sm ${tone()}`}
class={`mb-2 rounded-md border px-3 py-2 text-sm ${state.toneClass()}`}
role="status"
aria-live="polite"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="font-medium">
Pro Trial:
<Show when={daysRemaining() !== null} fallback={<span class="ml-2">Active</span>}>
<span class="ml-2">{daysRemaining()} days remaining</span>
</Show>
{TRIAL_BANNER_TITLE}
<span class="ml-2">{getTrialBannerStatusLabel(state.daysRemaining())}</span>
</div>
<Show when={!snoozed()}>
<Show when={state.showActions()}>
<div class="flex items-center gap-2">
<a
class="text-xs font-semibold underline underline-offset-2 hover:opacity-90"
href={getUpgradeActionUrlOrFallback('trial_banner')}
href={state.upgradeHref()}
target="_blank"
rel="noreferrer"
>
Upgrade
{TRIAL_BANNER_UPGRADE_LABEL}
</a>
<button
type="button"
class="text-xs opacity-70 hover:opacity-100"
onClick={handleSnooze}
onClick={state.handleSnooze}
>
Snooze 7d
{TRIAL_BANNER_SNOOZE_LABEL}
</button>
</div>
</Show>
@@ -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(() => <TrialBanner />);
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(() => <TrialBanner />);
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('snoozes and hides the action row', async () => {
licenseStatusMock.mockReturnValue({
subscription_state: 'trial',
trial_days_remaining: 2,
});
render(() => <TrialBanner />);
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();
});
});
});
@@ -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`;
}
@@ -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,
};
}
@@ -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');