mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
Split trial banner owners
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user