mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
Split active use trial nudge owners
This commit is contained in:
@@ -214,6 +214,14 @@ signals, canvas draw lifecycle, and resize handling, and
|
||||
math, hover target selection, tooltip time formatting, and density-cell
|
||||
opacity rules. Future density-map work should extend those owners instead of
|
||||
pushing canvas lifecycle or chart math back into the shared shell.
|
||||
The shared active-use trial nudge now follows that same owner split.
|
||||
`frontend-modern/src/components/shared/ActiveUseTrialNudge.tsx` stays the
|
||||
render shell, `frontend-modern/src/components/shared/useActiveUseTrialNudgeState.ts`
|
||||
owns first-seen persistence, snooze state, hourly age refresh, and trial-start
|
||||
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 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,
|
||||
|
||||
@@ -1,132 +1,41 @@
|
||||
/**
|
||||
* ActiveUseTrialNudge
|
||||
*
|
||||
* Proactive trial nudge shown after 7+ days of active use for free-tier users
|
||||
* who haven't started a trial. Dismissible with 7-day snooze.
|
||||
*/
|
||||
|
||||
import { Component, Show, createSignal, createMemo, onMount, onCleanup } from 'solid-js';
|
||||
import { licenseStatus, startProTrial } from '@/stores/license';
|
||||
import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { Component, Show } from 'solid-js';
|
||||
import {
|
||||
getProTrialStartedMessage,
|
||||
getTrialAlreadyUsedMessage,
|
||||
getTrialStartErrorMessage,
|
||||
getTrialTryAgainLaterMessage,
|
||||
} from '@/utils/upgradePresentation';
|
||||
|
||||
const SNOOZE_KEY = 'pulse_active_use_nudge_snoozed';
|
||||
const FIRST_SEEN_KEY = 'pulse_first_seen_ts';
|
||||
const MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function getFirstSeenTimestamp(): number {
|
||||
if (typeof window === 'undefined') return Date.now();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(FIRST_SEEN_KEY);
|
||||
if (raw) {
|
||||
const ts = Number(raw);
|
||||
if (Number.isFinite(ts) && ts > 0) return ts;
|
||||
}
|
||||
// First time: record now
|
||||
const now = Date.now();
|
||||
window.localStorage.setItem(FIRST_SEEN_KEY, String(now));
|
||||
return now;
|
||||
} catch {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
ACTIVE_USE_TRIAL_NUDGE_SNOOZE_LABEL,
|
||||
ACTIVE_USE_TRIAL_NUDGE_STARTING_LABEL,
|
||||
ACTIVE_USE_TRIAL_NUDGE_START_LABEL,
|
||||
ACTIVE_USE_TRIAL_NUDGE_TITLE,
|
||||
} from './activeUseTrialNudgeModel';
|
||||
import { useActiveUseTrialNudgeState } from './useActiveUseTrialNudgeState';
|
||||
|
||||
export const ActiveUseTrialNudge: Component = () => {
|
||||
const [snoozed, setSnoozed] = createSignal(isUpsellSnoozed(SNOOZE_KEY));
|
||||
const [firstSeen, setFirstSeen] = createSignal(Date.now());
|
||||
const [now, setNow] = createSignal(Date.now());
|
||||
const [startingTrial, setStartingTrial] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
setFirstSeen(getFirstSeenTimestamp());
|
||||
// Re-evaluate once per hour so the nudge can appear if day 7 is crossed during a long session
|
||||
const timer = setInterval(() => setNow(Date.now()), 60 * 60 * 1000);
|
||||
onCleanup(() => clearInterval(timer));
|
||||
});
|
||||
|
||||
const isOldEnough = createMemo(() => now() - firstSeen() >= MIN_AGE_MS);
|
||||
|
||||
const isFreeNoTrial = createMemo(() => {
|
||||
const ent = licenseStatus();
|
||||
if (!ent) return false;
|
||||
// Only show for free tier users who are trial-eligible
|
||||
return (
|
||||
ent.tier === 'free' &&
|
||||
ent.subscription_state !== 'trial' &&
|
||||
ent.subscription_state !== 'active' &&
|
||||
ent.trial_eligible !== false
|
||||
);
|
||||
});
|
||||
|
||||
const shouldShow = createMemo(() => isOldEnough() && isFreeNoTrial() && !snoozed());
|
||||
|
||||
const handleSnooze = () => {
|
||||
snoozeUpsell(SNOOZE_KEY);
|
||||
setSnoozed(true);
|
||||
};
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setStartingTrial(false);
|
||||
}
|
||||
};
|
||||
const state = useActiveUseTrialNudgeState();
|
||||
|
||||
return (
|
||||
<Show when={shouldShow()}>
|
||||
<Show when={state.shouldShow()}>
|
||||
<div
|
||||
class="mb-2 rounded-md border border-indigo-200 bg-indigo-50 dark:border-indigo-900 dark:bg-indigo-900/30 px-3 py-2 text-sm text-indigo-900 dark:text-indigo-100"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-medium">
|
||||
Experience the full power of Pulse — start your free trial
|
||||
</span>
|
||||
<span class="font-medium">{ACTIVE_USE_TRIAL_NUDGE_TITLE}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 hover:underline disabled:opacity-60"
|
||||
disabled={startingTrial()}
|
||||
onClick={handleStartTrial}
|
||||
disabled={state.startingTrial()}
|
||||
onClick={state.handleStartTrial}
|
||||
>
|
||||
{startingTrial() ? 'Starting...' : 'Start 14-day trial'}
|
||||
{state.startingTrial()
|
||||
? ACTIVE_USE_TRIAL_NUDGE_STARTING_LABEL
|
||||
: ACTIVE_USE_TRIAL_NUDGE_START_LABEL}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={handleSnooze}
|
||||
onClick={state.handleSnooze}
|
||||
>
|
||||
Snooze 7d
|
||||
{ACTIVE_USE_TRIAL_NUDGE_SNOOZE_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import calloutCardSource from '@/components/shared/CalloutCard.tsx?raw';
|
||||
import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw';
|
||||
import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw';
|
||||
import activeUseTrialNudgeSource from '@/components/shared/ActiveUseTrialNudge.tsx?raw';
|
||||
import activeUseTrialNudgeModelSource from '@/components/shared/activeUseTrialNudgeModel.ts?raw';
|
||||
import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw';
|
||||
import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw';
|
||||
import containerUpdateBadgeSource from '@/components/shared/ContainerUpdateBadge.tsx?raw';
|
||||
@@ -46,6 +48,7 @@ import monitoredSystemLimitWarningBannerSource from '@/components/shared/Monitor
|
||||
import selectionCardGroupSource from '@/components/shared/SelectionCardGroup.tsx?raw';
|
||||
import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
|
||||
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
|
||||
import activeUseTrialNudgeStateSource from '@/components/shared/useActiveUseTrialNudgeState.ts?raw';
|
||||
import collapsibleSearchInputStateSource from '@/components/shared/useCollapsibleSearchInputState.ts?raw';
|
||||
import containerUpdateButtonStateSource from '@/components/shared/useContainerUpdateButtonState.ts?raw';
|
||||
import densityMapStateSource from '@/components/shared/useDensityMapState.ts?raw';
|
||||
@@ -147,6 +150,32 @@ describe('shared primitive guardrails', () => {
|
||||
expect(commandPaletteModelSource).toContain('filterCommandPaletteCommands');
|
||||
});
|
||||
|
||||
it('keeps active use trial nudge on shell, runtime, and model owners', () => {
|
||||
expect(activeUseTrialNudgeSource).toContain('useActiveUseTrialNudgeState');
|
||||
expect(activeUseTrialNudgeSource).toContain('ACTIVE_USE_TRIAL_NUDGE_TITLE');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('createSignal');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('createMemo');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('startProTrial');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('localStorage');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('setInterval');
|
||||
|
||||
expect(activeUseTrialNudgeStateSource).toContain(
|
||||
'export function useActiveUseTrialNudgeState',
|
||||
);
|
||||
expect(activeUseTrialNudgeStateSource).toContain('createSignal');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('createMemo');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('window.localStorage');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('setInterval');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('startProTrial');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('snoozeUpsell');
|
||||
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('isActiveUseTrialNudgeEligible');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('isActiveUseTrialNudgeOldEnough');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_TITLE');
|
||||
});
|
||||
|
||||
it('routes settings info callouts through CalloutCard', () => {
|
||||
expect(calloutCardSource).toContain(
|
||||
"type CalloutTone = 'danger' | 'info' | 'success' | 'warning'",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import activeUseTrialNudgeSource from '@/components/shared/ActiveUseTrialNudge.tsx?raw';
|
||||
import activeUseTrialNudgeModelSource from '@/components/shared/activeUseTrialNudgeModel.ts?raw';
|
||||
import activeUseTrialNudgeStateSource from '@/components/shared/useActiveUseTrialNudgeState.ts?raw';
|
||||
import {
|
||||
ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY,
|
||||
ACTIVE_USE_TRIAL_NUDGE_MIN_AGE_MS,
|
||||
ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY,
|
||||
} from '@/components/shared/activeUseTrialNudgeModel';
|
||||
|
||||
const {
|
||||
licenseStatusMock,
|
||||
startProTrialMock,
|
||||
showSuccessMock,
|
||||
showErrorMock,
|
||||
isUpsellSnoozedMock,
|
||||
snoozeUpsellMock,
|
||||
} = vi.hoisted(() => ({
|
||||
licenseStatusMock: vi.fn(),
|
||||
startProTrialMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
showErrorMock: vi.fn(),
|
||||
isUpsellSnoozedMock: vi.fn(),
|
||||
snoozeUpsellMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/license', () => ({
|
||||
licenseStatus: (...args: unknown[]) => licenseStatusMock(...args),
|
||||
startProTrial: (...args: unknown[]) => startProTrialMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: (...args: unknown[]) => showSuccessMock(...args),
|
||||
error: (...args: unknown[]) => showErrorMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/snooze', () => ({
|
||||
isUpsellSnoozed: (...args: unknown[]) => isUpsellSnoozedMock(...args),
|
||||
snoozeUpsell: (...args: unknown[]) => snoozeUpsellMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/upgradePresentation', () => ({
|
||||
getProTrialStartedMessage: () => 'Pro trial started',
|
||||
getTrialAlreadyUsedMessage: () => 'Trial already used',
|
||||
getTrialStartErrorMessage: () => 'Trial start failed',
|
||||
getTrialTryAgainLaterMessage: () => 'Try again later',
|
||||
}));
|
||||
|
||||
import { ActiveUseTrialNudge } from '@/components/shared/ActiveUseTrialNudge';
|
||||
|
||||
function setEligibleFreeLicense() {
|
||||
licenseStatusMock.mockReturnValue({
|
||||
tier: 'free',
|
||||
subscription_state: 'expired',
|
||||
trial_eligible: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('ActiveUseTrialNudge', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
licenseStatusMock.mockReset();
|
||||
startProTrialMock.mockReset();
|
||||
showSuccessMock.mockReset();
|
||||
showErrorMock.mockReset();
|
||||
isUpsellSnoozedMock.mockReset();
|
||||
snoozeUpsellMock.mockReset();
|
||||
isUpsellSnoozedMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps active use trial nudge on shell, runtime, and model owners', () => {
|
||||
expect(activeUseTrialNudgeSource).toContain('useActiveUseTrialNudgeState');
|
||||
expect(activeUseTrialNudgeSource).toContain('ACTIVE_USE_TRIAL_NUDGE_TITLE');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('createSignal');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('createMemo');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('startProTrial');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('localStorage');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('setInterval');
|
||||
|
||||
expect(activeUseTrialNudgeStateSource).toContain('export function useActiveUseTrialNudgeState');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('createSignal');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('createMemo');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('window.localStorage');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('setInterval');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('startProTrial');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('snoozeUpsell');
|
||||
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('isActiveUseTrialNudgeEligible');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('isActiveUseTrialNudgeOldEnough');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_TITLE');
|
||||
});
|
||||
|
||||
it('renders for eligible free users after the minimum active age', async () => {
|
||||
setEligibleFreeLicense();
|
||||
localStorage.setItem(
|
||||
ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY,
|
||||
String(Date.now() - ACTIVE_USE_TRIAL_NUDGE_MIN_AGE_MS - 1),
|
||||
);
|
||||
|
||||
render(() => <ActiveUseTrialNudge />);
|
||||
|
||||
expect(await screen.findByRole('status')).toBeInTheDocument();
|
||||
expect(screen.getByText('Experience the full power of Pulse — start your free trial')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Start 14-day trial' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render before the active age threshold is crossed', () => {
|
||||
setEligibleFreeLicense();
|
||||
render(() => <ActiveUseTrialNudge />);
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
expect(localStorage.getItem(ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('snoozes and hides the nudge', async () => {
|
||||
setEligibleFreeLicense();
|
||||
localStorage.setItem(
|
||||
ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY,
|
||||
String(Date.now() - ACTIVE_USE_TRIAL_NUDGE_MIN_AGE_MS - 1),
|
||||
);
|
||||
|
||||
render(() => <ActiveUseTrialNudge />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Snooze 7d' }));
|
||||
|
||||
expect(snoozeUpsellMock).toHaveBeenCalledWith(ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a trial successfully and shows a success notification', async () => {
|
||||
setEligibleFreeLicense();
|
||||
startProTrialMock.mockResolvedValue({ outcome: 'activated' });
|
||||
localStorage.setItem(
|
||||
ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY,
|
||||
String(Date.now() - ACTIVE_USE_TRIAL_NUDGE_MIN_AGE_MS - 1),
|
||||
);
|
||||
|
||||
render(() => <ActiveUseTrialNudge />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Start 14-day trial' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startProTrialMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(showSuccessMock).toHaveBeenCalledWith('Pro trial started');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { LicenseEntitlements } from '@/api/license';
|
||||
|
||||
export const ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY = 'pulse_active_use_nudge_snoozed';
|
||||
export const ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY = 'pulse_first_seen_ts';
|
||||
export const ACTIVE_USE_TRIAL_NUDGE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const ACTIVE_USE_TRIAL_NUDGE_REFRESH_MS = 60 * 60 * 1000;
|
||||
|
||||
export const ACTIVE_USE_TRIAL_NUDGE_TITLE =
|
||||
'Experience the full power of Pulse — start your free trial';
|
||||
export const ACTIVE_USE_TRIAL_NUDGE_START_LABEL = 'Start 14-day trial';
|
||||
export const ACTIVE_USE_TRIAL_NUDGE_STARTING_LABEL = 'Starting...';
|
||||
export const ACTIVE_USE_TRIAL_NUDGE_SNOOZE_LABEL = 'Snooze 7d';
|
||||
|
||||
export function isActiveUseTrialNudgeEligible(
|
||||
entitlements: LicenseEntitlements | null | undefined,
|
||||
): boolean {
|
||||
if (!entitlements) return false;
|
||||
|
||||
return (
|
||||
entitlements.tier === 'free' &&
|
||||
entitlements.subscription_state !== 'trial' &&
|
||||
entitlements.subscription_state !== 'active' &&
|
||||
entitlements.trial_eligible !== false
|
||||
);
|
||||
}
|
||||
|
||||
export function isActiveUseTrialNudgeOldEnough(now: number, firstSeen: number): boolean {
|
||||
return now - firstSeen >= ACTIVE_USE_TRIAL_NUDGE_MIN_AGE_MS;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { licenseStatus, startProTrial } from '@/stores/license';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze';
|
||||
import {
|
||||
getProTrialStartedMessage,
|
||||
getTrialAlreadyUsedMessage,
|
||||
getTrialStartErrorMessage,
|
||||
getTrialTryAgainLaterMessage,
|
||||
} from '@/utils/upgradePresentation';
|
||||
import {
|
||||
ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY,
|
||||
ACTIVE_USE_TRIAL_NUDGE_REFRESH_MS,
|
||||
ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY,
|
||||
isActiveUseTrialNudgeEligible,
|
||||
isActiveUseTrialNudgeOldEnough,
|
||||
} from './activeUseTrialNudgeModel';
|
||||
|
||||
function getActiveUseTrialNudgeFirstSeenTimestamp(): number {
|
||||
if (typeof window === 'undefined') return Date.now();
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY);
|
||||
if (raw) {
|
||||
const timestamp = Number(raw);
|
||||
if (Number.isFinite(timestamp) && timestamp > 0) return timestamp;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
window.localStorage.setItem(ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY, String(now));
|
||||
return now;
|
||||
} catch {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
export function useActiveUseTrialNudgeState() {
|
||||
const [snoozed, setSnoozed] = createSignal(
|
||||
isUpsellSnoozed(ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY),
|
||||
);
|
||||
const [firstSeen, setFirstSeen] = createSignal(Date.now());
|
||||
const [now, setNow] = createSignal(Date.now());
|
||||
const [startingTrial, setStartingTrial] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
setFirstSeen(getActiveUseTrialNudgeFirstSeenTimestamp());
|
||||
const timer = window.setInterval(() => setNow(Date.now()), ACTIVE_USE_TRIAL_NUDGE_REFRESH_MS);
|
||||
onCleanup(() => window.clearInterval(timer));
|
||||
});
|
||||
|
||||
const shouldShow = createMemo(() => {
|
||||
return (
|
||||
isActiveUseTrialNudgeOldEnough(now(), firstSeen()) &&
|
||||
isActiveUseTrialNudgeEligible(licenseStatus()) &&
|
||||
!snoozed()
|
||||
);
|
||||
});
|
||||
|
||||
const handleSnooze = () => {
|
||||
snoozeUpsell(ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY);
|
||||
setSnoozed(true);
|
||||
};
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
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) {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setStartingTrial(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleSnooze,
|
||||
handleStartTrial,
|
||||
shouldShow,
|
||||
startingTrial,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import alertTargetTypesSource from '@/utils/alertTargetTypes.ts?raw';
|
||||
import resourceBadgesSource from '@/components/Infrastructure/resourceBadges.ts?raw';
|
||||
import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw';
|
||||
import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw';
|
||||
import activeUseTrialNudgeSource from '@/components/shared/ActiveUseTrialNudge.tsx?raw';
|
||||
import activeUseTrialNudgeModelSource from '@/components/shared/activeUseTrialNudgeModel.ts?raw';
|
||||
import collapsibleSearchInputSource from '@/components/shared/CollapsibleSearchInput.tsx?raw';
|
||||
import collapsibleSearchInputModelSource from '@/components/shared/collapsibleSearchInputModel.ts?raw';
|
||||
import containerUpdateBadgeSource from '@/components/shared/ContainerUpdateBadge.tsx?raw';
|
||||
@@ -47,6 +49,7 @@ import interactiveSparklineModelSource from '@/components/shared/interactiveSpar
|
||||
import infrastructureSelectorModelSource from '@/components/shared/infrastructureSelectorModel.ts?raw';
|
||||
import sharedInfrastructureSummaryTableModelSource from '@/components/shared/infrastructureSummaryTableModel.ts?raw';
|
||||
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
|
||||
import activeUseTrialNudgeStateSource from '@/components/shared/useActiveUseTrialNudgeState.ts?raw';
|
||||
import collapsibleSearchInputStateSource from '@/components/shared/useCollapsibleSearchInputState.ts?raw';
|
||||
import containerUpdateButtonStateSource from '@/components/shared/useContainerUpdateButtonState.ts?raw';
|
||||
import dialogStateSource from '@/components/shared/useDialogState.ts?raw';
|
||||
@@ -2709,6 +2712,24 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverPositionClass');
|
||||
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerVariant');
|
||||
expect(searchTipsPopoverModelSource).toContain('shouldSearchTipsPopoverOpenOnHover');
|
||||
expect(activeUseTrialNudgeSource).toContain('useActiveUseTrialNudgeState');
|
||||
expect(activeUseTrialNudgeSource).toContain('ACTIVE_USE_TRIAL_NUDGE_TITLE');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('createSignal');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('createMemo');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('startProTrial');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('localStorage');
|
||||
expect(activeUseTrialNudgeSource).not.toContain('setInterval');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('createSignal');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('createMemo');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('window.localStorage');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('setInterval');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('startProTrial');
|
||||
expect(activeUseTrialNudgeStateSource).toContain('snoozeUpsell');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_SNOOZE_KEY');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_FIRST_SEEN_KEY');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('isActiveUseTrialNudgeEligible');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('isActiveUseTrialNudgeOldEnough');
|
||||
expect(activeUseTrialNudgeModelSource).toContain('ACTIVE_USE_TRIAL_NUDGE_TITLE');
|
||||
expect(whatsNewModalSource).toContain('useWhatsNewModalState');
|
||||
expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS');
|
||||
expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal');
|
||||
|
||||
Reference in New Issue
Block a user